mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-28 05:48:12 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8326ddea | ||
|
|
5d4b11d287 | ||
|
|
096d477893 | ||
|
|
153f240c43 | ||
|
|
df46db965a | ||
|
|
7ef5203564 | ||
|
|
9420abfacc | ||
|
|
16f93397ae | ||
|
|
016a4a422a | ||
|
|
996b06cecc | ||
|
|
571b7387e7 | ||
|
|
b6d468376a | ||
|
|
d8fc7e40a1 | ||
|
|
e875ed739b | ||
|
|
aa5ce4a2f1 | ||
|
|
80969012c9 | ||
|
|
caaee7e9bf | ||
|
|
f96fd36ecb | ||
|
|
495dc55144 | ||
|
|
8795f0f32c | ||
|
|
a0f54b4871 | ||
|
|
68dfbcc2eb | ||
|
|
82493d1019 | ||
|
|
3d6df0c044 | ||
|
|
2583016693 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.7.0
|
||||
current_version = 0.8.0
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
search = version="{current_version}"
|
||||
|
||||
+10
-11
@@ -1,8 +1,8 @@
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
dist: xenial
|
||||
sudo: required
|
||||
node_js:
|
||||
- "8"
|
||||
- 8
|
||||
cache:
|
||||
pip: true
|
||||
install:
|
||||
@@ -15,15 +15,14 @@ install:
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- name: "Branch Tests"
|
||||
script:
|
||||
- set -eo pipefail
|
||||
- flake8 server
|
||||
- black --check
|
||||
- npm run --prefix client/ build
|
||||
- npm run --prefix client/ unit-test
|
||||
- pytest -s server/test
|
||||
- name: "Branch Tests 3.7"
|
||||
python: "3.7"
|
||||
script: ./travis-build.sh
|
||||
- name: "Branch Tests 3.6"
|
||||
python: "3.6"
|
||||
script: ./travis-build.sh
|
||||
- name: "Smoke Tests"
|
||||
python: "3.6"
|
||||
if: branch = master AND type = cron
|
||||
script:
|
||||
- npm run --prefix client/ smoke-test
|
||||
|
||||
@@ -37,7 +37,7 @@ You should see your web browser open with the following
|
||||
|
||||
There are several options available, such as:
|
||||
|
||||
- `--layout` to specify the layout as `tsne` or `umap`
|
||||
- `--layout` to specify the layout as `tsne`, `umap`, `diffmap`, `phate`, `draw_graph_fa`, or `draw_graph_fr`
|
||||
- `--title` to show a title on the explorer
|
||||
- `--open` to automatically open the web browser after launching (OS X only)
|
||||
|
||||
@@ -57,7 +57,7 @@ The `launch` command assumes that the data is stored in the `.h5ad` format from
|
||||
|
||||
- an `obs` field has a unique identifier for every cell (you can specify which field to use with the `--obs-names` option, by default it will use the value of `data.obs_names`)
|
||||
- a `var` field has a unique identifier for every gene (you can specify which field to use with the `--var-names` option, by default it will use the value of `data.var_names`)
|
||||
- an `obsm` field contains the two-dimensional coordinates for the layout that you want to render (e.g. `X_tsne` for the `tsne` layout or `X_umap` for the `umap` layout)
|
||||
- an `obsm` field contains the two-dimensional coordinates for the layout that you want to render (e.g. `X_umap` for the `umap` layout)
|
||||
- any additional `obs` fields will be rendered as per-cell continuous or categorical metadata by the app (e.g. `louvain` cluster assignments)
|
||||
|
||||
### prepare
|
||||
@@ -240,10 +240,10 @@ Then clone the project
|
||||
git clone https://github.com/chanzuckerberg/cellxgene.git
|
||||
```
|
||||
|
||||
Build the client web assets by calling this from inside the `cellxgene` folder
|
||||
Build the client web assets by calling `make` from inside the `cellxgene` folder
|
||||
|
||||
```
|
||||
./bin/build-client
|
||||
make
|
||||
```
|
||||
|
||||
Install all requirements (we recommend doing this inside a virtual environment)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const jest_env = process.env.JEST_ENV || "dev";
|
||||
export const appPort = process.env.JEST_CXG_PORT || 3000;
|
||||
export const appUrlBase = `http://localhost:${appPort}`;
|
||||
export const DEV = jest_env === "dev";
|
||||
export const DEBUG = jest_env === "debug";
|
||||
export const DATASET = "pbmc3k";
|
||||
@@ -0,0 +1,105 @@
|
||||
export const datasets = {
|
||||
pbmc3k: {
|
||||
title: "cellxgene: pbmc3k",
|
||||
dataframe: {
|
||||
nObs: "2638",
|
||||
nVar: "1838",
|
||||
type: "float32"
|
||||
},
|
||||
categorical: {
|
||||
louvain: {
|
||||
"B cells": "342",
|
||||
"CD14+ Monocytes": "480",
|
||||
"CD4 T cells": "1144",
|
||||
"CD8 T cells": "316",
|
||||
"Dendritic cells": "37",
|
||||
"FCGR3A+ Monocytes": "150",
|
||||
Megakaryocytes: "15",
|
||||
"NK cells": "154"
|
||||
}
|
||||
},
|
||||
continuous: {
|
||||
n_genes: "int32",
|
||||
percent_mito: "float32",
|
||||
n_counts: "float32"
|
||||
},
|
||||
cellsets: {
|
||||
lasso: [
|
||||
{
|
||||
"coordinates-as-percent": { x1: 0.25, y1: 0.25, x2: 0.35, y2: 0.35 },
|
||||
count: "26"
|
||||
}
|
||||
],
|
||||
categorical: [
|
||||
{
|
||||
metadata: "louvain",
|
||||
values: ["B cells", "Megakaryocytes"],
|
||||
count: "357"
|
||||
}
|
||||
],
|
||||
continuous: [
|
||||
{
|
||||
metadata: "n_genes",
|
||||
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
|
||||
count: "1537"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
diffexp: {
|
||||
cellset1: [
|
||||
{ kind: "categorical", metadata: "louvain", values: ["B cells"] }
|
||||
],
|
||||
cellset2: [
|
||||
{
|
||||
kind: "categorical",
|
||||
metadata: "louvain",
|
||||
values: ["CD4 T cells", "NK cells"]
|
||||
}
|
||||
],
|
||||
"gene-results": [
|
||||
"HLA-DRB1",
|
||||
"HLA-DPB1",
|
||||
"CD79A",
|
||||
"HLA-DPA1",
|
||||
"HLA-DQA1",
|
||||
"CD79B",
|
||||
"HLA-DQB1",
|
||||
"MS4A1",
|
||||
"IL32",
|
||||
"CD37"
|
||||
]
|
||||
},
|
||||
|
||||
genes: {
|
||||
bulkadd: ["S100A8", "FCGR3A", "LGALS2", "GSTP1"],
|
||||
search: "ACD"
|
||||
},
|
||||
subset: {
|
||||
cellset1: [
|
||||
{
|
||||
kind: "categorical",
|
||||
metadata: "louvain",
|
||||
values: ["B cells", "Megakaryocytes"]
|
||||
}
|
||||
],
|
||||
count: "357",
|
||||
categorical: {
|
||||
louvain: {
|
||||
"B cells": "342",
|
||||
Megakaryocytes: "15"
|
||||
}
|
||||
},
|
||||
lasso: {
|
||||
"coordinates-as-percent": { x1: 0.45, y1: 0.45, x2: 0.5, y2: 0.5 },
|
||||
count: "67"
|
||||
}
|
||||
},
|
||||
scatter: {
|
||||
genes: { x: "S100A8", y: "FCGR3A" }
|
||||
},
|
||||
pan: {
|
||||
"coordinates-as-percent": { x1: 0.75, y1: 0.75, x2: 0.35, y2: 0.35 }
|
||||
}
|
||||
}
|
||||
};
|
||||
+250
-151
@@ -1,185 +1,284 @@
|
||||
import puppeteer from "puppeteer";
|
||||
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
|
||||
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
|
||||
import { datasets } from "./data";
|
||||
|
||||
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;
|
||||
let browser, page, utils, cxgActions, spy;
|
||||
const browserViewport = { width: 1280, height: 960 };
|
||||
let data = datasets[DATASET];
|
||||
|
||||
if (DEBUG) jest.setTimeout(100000);
|
||||
if (DEV) jest.setTimeout(10000);
|
||||
|
||||
beforeAll(async () => {
|
||||
const browser_params = DEV
|
||||
const browserParams = DEV
|
||||
? { headless: false, slowMo: 5 }
|
||||
: DEBUG
|
||||
? { headless: false, slowMo: 100, devtools: true }
|
||||
: {};
|
||||
browser = await puppeteer.launch(browser_params);
|
||||
browser = await puppeteer.launch(browserParams);
|
||||
page = await browser.newPage();
|
||||
page.setViewport(browserViewport);
|
||||
if (DEV) page.on("console", msg => console.log("PAGE LOG:", msg.text()));
|
||||
await page.setViewport(browserViewport);
|
||||
if (DEV || DEBUG) {
|
||||
page.on("console", msg => console.log(`PAGE LOG: ${msg.text()}`));
|
||||
}
|
||||
page.on("pageerror", err => {
|
||||
throw new Error(`Console error: ${err}`);
|
||||
});
|
||||
utils = puppeteerUtils(page);
|
||||
cxgActions = cellxgeneActions(page);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await page.goto(appUrlBase);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (!DEV) {
|
||||
if (!DEBUG) {
|
||||
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", () => {
|
||||
describe("did launch", async () => {
|
||||
test("page launched", async () => {
|
||||
await page.goto(appUrlBase);
|
||||
let el = await getOneElementInnerHTML("[data-testid='header']");
|
||||
expect(el).toBe("cellxgene: pbmc3k");
|
||||
let el = await utils.getOneElementInnerHTML("[data-testid='header']");
|
||||
expect(el).toBe(data.title);
|
||||
});
|
||||
});
|
||||
|
||||
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("metadata loads", async () => {
|
||||
test("categories and values from dataset appear", async () => {
|
||||
for (const label in data.categorical) {
|
||||
await utils.waitByID(`category-${label}`);
|
||||
const categoryName = await utils.getOneElementInnerText(
|
||||
`[data-testid="category-${label}"]`
|
||||
);
|
||||
expect(categoryName).toMatch(label);
|
||||
await utils.clickOn(`category-expand-${label}`);
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("continuous data appears", async () => {
|
||||
for (const label in data.continuous) {
|
||||
await utils.waitByID(`histogram-${label}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("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/);
|
||||
describe("cell selection", async () => {
|
||||
test("selects all cells cellset 1", async () => {
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(data.dataframe.nObs);
|
||||
});
|
||||
|
||||
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 all cells cellset 2", async () => {
|
||||
const cellCount = await cxgActions.cellSet(2);
|
||||
expect(cellCount).toBe(data.dataframe.nObs);
|
||||
});
|
||||
|
||||
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)
|
||||
test("selects cells via lasso", async () => {
|
||||
for (const cellset of data.cellsets.lasso) {
|
||||
const cellset1 = await cxgActions.calcDragCoordinates(
|
||||
"layout-graph",
|
||||
cellset["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
|
||||
test("selects cells via categorical", async () => {
|
||||
for (const cellset of data.cellsets.categorical) {
|
||||
await utils.clickOn(`category-expand-${cellset.metadata}`);
|
||||
await utils.clickOn(`category-select-${cellset.metadata}`);
|
||||
for (const val of cellset.values) {
|
||||
await utils.clickOn(
|
||||
`categorical-value-select-${cellset.metadata}-${val}`
|
||||
);
|
||||
}
|
||||
);
|
||||
expect(diffexps).toMatchObject([
|
||||
"HLA-DPA1",
|
||||
"HLA-DQA1",
|
||||
"HLA-DRB1",
|
||||
"HLA-DMA",
|
||||
"CST3",
|
||||
"HLA-DPB1",
|
||||
"HLA-DQB1",
|
||||
"LGALS2",
|
||||
"FCER1A",
|
||||
"LTB"
|
||||
]);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
|
||||
test("selects cells via continuous", async () => {
|
||||
for (const cellset of data.cellsets.continuous) {
|
||||
const histId = `histogram-${cellset.metadata}-plot-brush`;
|
||||
const coords = await cxgActions.calcDragCoordinates(
|
||||
histId,
|
||||
cellset["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(histId, coords.start, coords.end);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("brushable histogram", () => {
|
||||
test("can brush historgram", async () => {
|
||||
await page.goto(appUrlBase);
|
||||
const hist = await page.waitForSelector(
|
||||
"[data-testid='histogram_n_genes_svg-brush'] > .overlay"
|
||||
describe("gene entry", async () => {
|
||||
test("search for single gene", async () => {
|
||||
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
|
||||
await utils.typeInto("gene-search", data.genes.search);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(
|
||||
`[data-testid='histogram-${data.genes.search}']`
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
test("bulk add genes", async () => {
|
||||
await cxgActions.reset();
|
||||
const testGenes = data.genes.bulkadd;
|
||||
await utils.clickOn("section-bulk-add");
|
||||
await utils.typeInto("input-bulk-add", testGenes.join(","));
|
||||
await page.keyboard.press("Enter");
|
||||
const userGeneHist = await cxgActions.getAllHistograms(
|
||||
"histogram-user-gene"
|
||||
);
|
||||
expect(userGeneHist).toEqual(expect.arrayContaining(testGenes));
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffexp", async () => {
|
||||
test("selects cells, saves them and performs diffexp", async () => {
|
||||
for (const select of data.diffexp.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await cxgActions.cellSet(1);
|
||||
for (const select of data.diffexp.cellset2) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await cxgActions.cellSet(2);
|
||||
await utils.clickOn("diffexp-button");
|
||||
const diffExpHists = await cxgActions.getAllHistograms("histogram-diffexp");
|
||||
expect(diffExpHists).toEqual(
|
||||
expect.arrayContaining(data.diffexp["gene-results"])
|
||||
);
|
||||
});
|
||||
});
|
||||
//
|
||||
|
||||
describe("subset/reset", async () => {
|
||||
test("subset - cell count matches", async () => {
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await utils.clickOn("subset-button");
|
||||
for (const label in data.subset.categorical) {
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.subset.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.subset.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reset after subset", async () => {
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await utils.clickOn("subset-button");
|
||||
for (const label in data.subset.categorical) {
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.subset.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.subset.categorical[label])
|
||||
);
|
||||
}
|
||||
await cxgActions.reset();
|
||||
for (const label in data.categorical) {
|
||||
await utils.waitByID(`category-${label}`);
|
||||
const categoryName = await utils.getOneElementInnerText(
|
||||
`[data-testid="category-${label}"]`
|
||||
);
|
||||
expect(categoryName).toMatch(label);
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("lasso after subset", async () => {
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await utils.clickOn("subset-button");
|
||||
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.subset.lasso["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(
|
||||
"layout-graph",
|
||||
lassoSelection.start,
|
||||
lassoSelection.end,
|
||||
true
|
||||
);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(data.subset.lasso.count);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scatter plot", async () => {
|
||||
test("scatter plot appears", async () => {
|
||||
await cxgActions.reset();
|
||||
const testGenes = data.scatter.genes;
|
||||
await utils.clickOn("section-bulk-add");
|
||||
await utils.typeInto("input-bulk-add", Object.values(testGenes).join(","));
|
||||
await page.keyboard.press("Enter");
|
||||
await utils.clickOn(`plot-x-${data.scatter.genes.x}`);
|
||||
await utils.clickOn(`plot-y-${data.scatter.genes.y}`);
|
||||
await utils.waitByID("scatterplot");
|
||||
});
|
||||
});
|
||||
|
||||
// interact with UI elements just that they do not break
|
||||
describe("ui elements don't error", async () => {
|
||||
test("color by", async () => {
|
||||
for (const label in data.categorical) {
|
||||
await utils.clickOn(`colorby-${label}`);
|
||||
}
|
||||
for (const label in data.continuous) {
|
||||
await utils.clickOn(`colorby-${label}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("pan and zoom", async () => {
|
||||
await utils.clickOn("mode-pan-zoom");
|
||||
const panCoords = await cxgActions.calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.pan["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(
|
||||
"layout-graph",
|
||||
panCoords.start,
|
||||
panCoords.end,
|
||||
false
|
||||
);
|
||||
await page.evaluate(`window.scrollBy(0, 1000);`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"preset": "jest-puppeteer",
|
||||
"testMatch": [
|
||||
"**/__tests__/**/?(*.)(spec|test).js?(x)"
|
||||
],
|
||||
"testURL": "http://localhost/",
|
||||
"setupFiles": [
|
||||
"../setupMissingGlobals.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
export const puppeteerUtils = puppeteerPage => ({
|
||||
async waitByID(testid) {
|
||||
return await puppeteerPage.waitForSelector(`[data-testid='${testid}']`);
|
||||
},
|
||||
|
||||
async waitByClass(testclass) {
|
||||
return await puppeteerPage.waitForSelector(
|
||||
`[data-testclass='${testclass}']`
|
||||
);
|
||||
},
|
||||
|
||||
async typeInto(testid, text) {
|
||||
// only works for text without special characters
|
||||
await this.waitByID(testid);
|
||||
// type ahead can be annoying if you don't pause before you type
|
||||
await puppeteerPage.click(`[data-testid='${testid}']`);
|
||||
await puppeteerPage.waitFor(200);
|
||||
await puppeteerPage.type(`[data-testid='${testid}']`, text);
|
||||
},
|
||||
|
||||
async clickOn(testid) {
|
||||
await this.waitByID(testid);
|
||||
await puppeteerPage.click(`[data-testid='${testid}']`);
|
||||
await puppeteerPage.waitFor(50);
|
||||
},
|
||||
|
||||
async getOneElementInnerHTML(selector) {
|
||||
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
|
||||
return text;
|
||||
},
|
||||
|
||||
async getOneElementInnerText(selector) {
|
||||
let text = await puppeteerPage.$eval(selector, el => el.innerText);
|
||||
return text;
|
||||
}
|
||||
});
|
||||
|
||||
export const cellxgeneActions = puppeteerPage => ({
|
||||
async drag(testid, start, end, lasso = false) {
|
||||
const layout = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||
const elBox = await layout.boxModel();
|
||||
const x1 = elBox.content[0].x + start.x;
|
||||
const x2 = elBox.content[0].x + end.x;
|
||||
const y1 = elBox.content[0].y + start.y;
|
||||
const y2 = elBox.content[0].y + end.y;
|
||||
await puppeteerPage.mouse.move(x1, y1);
|
||||
await puppeteerPage.mouse.down();
|
||||
if (lasso) {
|
||||
await puppeteerPage.mouse.move(x2, y1);
|
||||
await puppeteerPage.mouse.move(x2, y2);
|
||||
await puppeteerPage.mouse.move(x1, y2);
|
||||
await puppeteerPage.mouse.move(x1, y1);
|
||||
} else {
|
||||
await puppeteerPage.mouse.move(x2, y2);
|
||||
}
|
||||
await puppeteerPage.mouse.up();
|
||||
},
|
||||
|
||||
async getAllHistograms(testclass) {
|
||||
await puppeteerUtils(puppeteerPage).waitByClass(testclass);
|
||||
const histograms = await puppeteerPage.$$eval(
|
||||
`[data-testclass=${testclass}]`,
|
||||
els => {
|
||||
return els.map(el => {
|
||||
return el.dataset.testid.substring(
|
||||
"histogram_".length,
|
||||
el.dataset.testid.length
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
return histograms;
|
||||
},
|
||||
|
||||
async getAllCategoriesAndCounts(category) {
|
||||
await puppeteerUtils(puppeteerPage).waitByClass("categorical-row");
|
||||
const categories = await puppeteerPage.$$eval(
|
||||
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
|
||||
els => {
|
||||
let result = {};
|
||||
els.forEach(el => {
|
||||
const cat = el.querySelector("[data-testclass='categorical-value']")
|
||||
.innerText;
|
||||
const count = el.querySelector(
|
||||
"[data-testclass='categorical-value-count']"
|
||||
).innerText;
|
||||
result[cat] = count;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
);
|
||||
return categories;
|
||||
},
|
||||
|
||||
async cellSet(num) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(`cellset-button-${num}`);
|
||||
return await puppeteerUtils(puppeteerPage).getOneElementInnerText(
|
||||
`[data-testid='cellset-count-${num}']`
|
||||
);
|
||||
},
|
||||
|
||||
async resetCategory(category) {
|
||||
const checkboxId = `category-select-${category}`;
|
||||
await puppeteerUtils(puppeteerPage).waitByID(checkboxId);
|
||||
const checkedPseudoclass = await puppeteerPage.$eval(
|
||||
`[data-testid='${checkboxId}']`,
|
||||
el => {
|
||||
return el.matches(":checked");
|
||||
}
|
||||
);
|
||||
if (!checkedPseudoclass) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(checkboxId);
|
||||
}
|
||||
try {
|
||||
const categoryRow = await puppeteerUtils(puppeteerPage).waitByID(
|
||||
`category-expand-${category}`
|
||||
);
|
||||
const isExpanded = await categoryRow.$(
|
||||
"[data-testclass='category-expand-is-expanded']"
|
||||
);
|
||||
if (isExpanded) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(
|
||||
`category-expand-${category}`
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async calcDragCoordinates(testid, coordinateAsPercent) {
|
||||
const el = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||
const size = await el.boxModel();
|
||||
const coords = {
|
||||
start: {
|
||||
x: Math.floor(size.width * coordinateAsPercent.x1),
|
||||
y: Math.floor(size.height * coordinateAsPercent.y1)
|
||||
},
|
||||
end: {
|
||||
x: Math.floor(size.width * coordinateAsPercent.x2),
|
||||
y: Math.floor(size.height * coordinateAsPercent.y2)
|
||||
}
|
||||
};
|
||||
return coords;
|
||||
},
|
||||
|
||||
async selectCategory(category, values, reset = true) {
|
||||
if (reset) await this.resetCategory(category);
|
||||
await puppeteerUtils(puppeteerPage).clickOn(`category-expand-${category}`);
|
||||
await puppeteerUtils(puppeteerPage).clickOn(`category-select-${category}`);
|
||||
for (const val of values) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(
|
||||
`categorical-value-select-${category}-${val}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async reset() {
|
||||
await puppeteerUtils(puppeteerPage).clickOn("reset");
|
||||
// loading state never actually happens, reset is too fast
|
||||
await page.waitFor(200);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import cascadeReducers from "../../src/reducers/cascade";
|
||||
|
||||
describe("create", () => {
|
||||
test("from Array", () => {
|
||||
expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("from Map", () => {
|
||||
expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf(
|
||||
Function
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade", () => {
|
||||
test("expected arguments provided & cascade ordering", () => {
|
||||
const topLevelState = {};
|
||||
const topLevelAction = { type: "test" };
|
||||
|
||||
const reducer = cascadeReducers([
|
||||
[
|
||||
"foo",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({});
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 0;
|
||||
}
|
||||
],
|
||||
[
|
||||
"bar",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({ foo: 0 });
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 99;
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
const nextState = reducer(topLevelState, topLevelAction);
|
||||
expect(nextState).toStrictEqual({ foo: 0, bar: 99 });
|
||||
expect(topLevelState).toStrictEqual({});
|
||||
expect(topLevelAction).toStrictEqual({ type: "test" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import undoable from "../../src/reducers/undoable";
|
||||
|
||||
describe("create", () => {
|
||||
test("no keys", () => {
|
||||
expect(() => undoable(() => {})).toThrow();
|
||||
expect(() => undoable(() => {}, null)).toThrow();
|
||||
expect(() => undoable(() => {}, [])).toThrow();
|
||||
expect(() => undoable(() => {}, [], {})).toThrow();
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function);
|
||||
expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("handles undefined initial state", () => {
|
||||
expect(
|
||||
undoable(() => {}, ["a"])(undefined, { type: "test" })
|
||||
).toMatchObject({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo", () => {
|
||||
test("expected state modifications", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = state => {
|
||||
return { a: state.a + 1, b: state.b + 1 };
|
||||
};
|
||||
const undoableReducer = undoable(reducer, ["a"]);
|
||||
|
||||
const s1 = undoableReducer(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// test that only specified keys are undone
|
||||
const s2 = undoableReducer(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
|
||||
// test backstop when no more history
|
||||
const s3 = undoableReducer(s2, { type: "@@undoable/undo" });
|
||||
expect(s3).toMatchObject({ a: 0, b: 1001 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("redo", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = state => {
|
||||
return { a: state.a + 1, b: state.b + 1 };
|
||||
};
|
||||
let UR;
|
||||
|
||||
beforeEach(() => {
|
||||
UR = undoable(reducer, ["a"]);
|
||||
});
|
||||
|
||||
test("expected state modifications", () => {
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify undo->redo reverts state.
|
||||
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
|
||||
type: "@@undoable/redo"
|
||||
});
|
||||
expect(s2).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify backstop when no redo future
|
||||
const s3 = UR(s2, { type: "@@undoable/redo" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1001 });
|
||||
});
|
||||
|
||||
test("history cleared", () => {
|
||||
// verify future cleared upon a normal state transition
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
const s2 = UR(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
const s3 = UR(s2, { type: "test" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1002 });
|
||||
const s4 = UR(s3, { type: "@@undoable/redo" });
|
||||
expect(s4).toMatchObject({ a: 1, b: 1002 });
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
TODO:
|
||||
- historyLimit is enforced
|
||||
- action filters
|
||||
*/
|
||||
@@ -3,6 +3,7 @@ import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import * as World from "../../../src/util/stateManager/world";
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||
import { DimTypes } from "../../../src/util/typedCrossfilter/crossfilter";
|
||||
import * as REST from "./sampleResponses";
|
||||
import {
|
||||
obsAnnoDimensionName,
|
||||
@@ -26,15 +27,15 @@ const defaultBigBang = () => {
|
||||
/* create world */
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
/* create crossfilter */
|
||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||
/* create dimension map */
|
||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||
const crossfilter = World.createObsDimensions(
|
||||
new Crossfilter(world.obsAnnotations),
|
||||
world
|
||||
);
|
||||
|
||||
return {
|
||||
universe,
|
||||
world,
|
||||
crossfilter,
|
||||
dimensionMap
|
||||
crossfilter
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,13 +72,16 @@ describe("createWorldFromCurrentSelection", () => {
|
||||
const {
|
||||
universe,
|
||||
world: originalWorld,
|
||||
crossfilter,
|
||||
dimensionMap
|
||||
crossfilter: originalCrossfilter
|
||||
} = defaultBigBang();
|
||||
|
||||
/* mock a selection */
|
||||
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
|
||||
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
|
||||
let crossfilter = originalCrossfilter
|
||||
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
|
||||
.select(obsAnnoDimensionName("field3"), {
|
||||
mode: "exact",
|
||||
values: [false]
|
||||
});
|
||||
|
||||
/* create the world from the selection */
|
||||
const world = World.createWorldFromCurrentSelection(
|
||||
@@ -86,7 +90,7 @@ describe("createWorldFromCurrentSelection", () => {
|
||||
crossfilter
|
||||
);
|
||||
expect(world).toBeDefined();
|
||||
expect(world.nObs).toEqual(crossfilter.countFiltered());
|
||||
expect(world.nObs).toEqual(crossfilter.countSelected());
|
||||
|
||||
/*
|
||||
calculate expected values and match against result
|
||||
@@ -136,43 +140,32 @@ describe("createObsDimensionMap", () => {
|
||||
- check that dimension typing is sane
|
||||
*/
|
||||
|
||||
const { dimensionMap } = defaultBigBang();
|
||||
const { crossfilter } = defaultBigBang();
|
||||
const annotationNames = _.map(
|
||||
REST.schema.schema.annotations.obs,
|
||||
c => c.name
|
||||
);
|
||||
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name");
|
||||
expect(dimensionMap).toBeDefined();
|
||||
expect(crossfilter).toBeDefined();
|
||||
annotationNames.forEach(name => {
|
||||
const dim = dimensionMap[obsAnnoDimensionName(name)];
|
||||
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
|
||||
if (name === "name") {
|
||||
expect(dim).toBeUndefined();
|
||||
} else {
|
||||
const { type } = schemaByObsName[name];
|
||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||
expect(dim).toBeInstanceOf(Crossfilter.EnumDimension);
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
|
||||
} else {
|
||||
expect(dim).toBeInstanceOf(Crossfilter.ScalarDimension);
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(dimensionMap[layoutDimensionName("XY")]).toBeInstanceOf(
|
||||
Crossfilter.SpatialDimension
|
||||
);
|
||||
expect(
|
||||
crossfilter.dimensions[layoutDimensionName("XY")].dim
|
||||
).toBeInstanceOf(DimTypes.spatial);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVarDataDimension", () => {
|
||||
/* create default universe */
|
||||
const { world, crossfilter } = defaultBigBang();
|
||||
world.varData = world.varData.withCol(
|
||||
"GENE",
|
||||
Float32Array.from(_.range(world.nObs))
|
||||
);
|
||||
const result = World.createVarDataDimension(world, crossfilter, "GENE");
|
||||
expect(result).toBeInstanceOf(Crossfilter.ScalarDimension);
|
||||
});
|
||||
|
||||
describe("worldEqUniverse", () => {
|
||||
const { universe, world } = defaultBigBang();
|
||||
const result = World.worldEqUniverse(world, universe);
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import _ from "lodash";
|
||||
import { polygonContains } from "d3";
|
||||
|
||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||
|
||||
const someData = [
|
||||
{
|
||||
date: "2011-11-14T16:17:54Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001"],
|
||||
coords: [0, 0]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:20:19Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001", "005"],
|
||||
coords: [0.4, 0.4]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:28:54Z",
|
||||
quantity: 1,
|
||||
total: 300,
|
||||
tip: 200,
|
||||
type: "visa",
|
||||
productIDs: ["004", "005"],
|
||||
coords: [0.3, 0.1]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:30:43Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002"],
|
||||
coords: [0.392, 0.1]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:48:46Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["005"],
|
||||
coords: [0.7, 0.0482]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:53:41Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "004", "005"],
|
||||
coords: [0.9999, 1.0]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:54:06Z",
|
||||
quantity: 1,
|
||||
total: 100,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["001", "002", "003", "004", "005"],
|
||||
coords: [0.384, 0.6938]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:58:03Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001"],
|
||||
coords: [0.4822, 0.482]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:07:21Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["004", "005"],
|
||||
coords: [0.2234, 0]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:22:59Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002", "004", "005"],
|
||||
coords: [0.382, 0.38485]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:25:45Z",
|
||||
quantity: 2,
|
||||
total: 200,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["002"],
|
||||
coords: [0.998, 0.8472]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:29:52Z",
|
||||
quantity: 1,
|
||||
total: 200,
|
||||
tip: 100,
|
||||
type: "visa",
|
||||
productIDs: ["004"],
|
||||
coords: [0.8273, 0.3384]
|
||||
}
|
||||
];
|
||||
|
||||
let payments = null;
|
||||
beforeEach(() => {
|
||||
payments = new Crossfilter(someData);
|
||||
});
|
||||
|
||||
describe("ImmutableTypedCrossfilter", () => {
|
||||
test("create crossfilter", () => {
|
||||
expect(payments).toBeDefined();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
|
||||
const p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.select("quantity", { mode: "all" });
|
||||
expect(p).toBeDefined();
|
||||
expect(p.all()).toEqual(someData);
|
||||
expect(p.size()).toEqual(someData.length);
|
||||
expect(p.isElementSelected(0)).toBeTruthy();
|
||||
expect(p.countSelected()).toEqual(someData.length);
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("immutability", () => {
|
||||
/*
|
||||
the following should return a new crossfilter:
|
||||
- addDimension()
|
||||
- delDimension()
|
||||
- select
|
||||
*/
|
||||
const p2 = payments.addDimension(
|
||||
"quantity",
|
||||
"scalar",
|
||||
(i, data) => data[i].quantity,
|
||||
Int32Array
|
||||
);
|
||||
|
||||
expect(payments).not.toBe(p2);
|
||||
const p3 = p2.select("quantity", { mode: "all" });
|
||||
expect(p3).not.toBe(p2);
|
||||
|
||||
const p4 = p3.delDimension("quantity");
|
||||
expect(p4).not.toBe(p3);
|
||||
});
|
||||
|
||||
test("select all and none", () => {
|
||||
let p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
|
||||
.addDimension("total", "scalar", (i, d) => d[i].total, Float32Array)
|
||||
.addDimension("type", "enum", (i, d) => d[i].type);
|
||||
expect(p).toBeDefined();
|
||||
|
||||
/* expect all records to be selected - default init state */
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
expect(p.countSelected()).toEqual(someData.length);
|
||||
expect(p.allSelectedMask()).toEqual(
|
||||
new Uint8Array(someData.length).fill(1)
|
||||
);
|
||||
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
|
||||
new Uint8Array(someData.length).fill(99)
|
||||
);
|
||||
for (let i = 0; i < someData.length; i += 1) {
|
||||
expect(p.isElementSelected(i)).toBeTruthy();
|
||||
}
|
||||
|
||||
/* expect a selectAll on one dimension to change nothing */
|
||||
p = p.select("tip", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
|
||||
/* ditto */
|
||||
p = p.select("quantity", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
|
||||
/* select none on one dimension */
|
||||
p = p.select("type", { mode: "none" });
|
||||
expect(p.allSelected()).toEqual([]);
|
||||
expect(p.countSelected()).toEqual(0);
|
||||
expect(p.allSelectedMask()).toEqual(
|
||||
new Uint8Array(someData.length).fill(0)
|
||||
);
|
||||
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
|
||||
new Uint8Array(someData.length).fill(0)
|
||||
);
|
||||
for (let i = 0; i < someData.length; i += 1) {
|
||||
expect(p.isElementSelected(i)).toBeFalsy();
|
||||
}
|
||||
|
||||
p = p.select("quantity", { mode: "none" });
|
||||
expect(p.allSelected()).toEqual([]);
|
||||
|
||||
// invert the first none; should have no effect because type is
|
||||
// still not filtered.
|
||||
p = p.select("quantity", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual([]);
|
||||
|
||||
/* select all of type; should select all records */
|
||||
p = p.select("type", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
});
|
||||
|
||||
describe("scalar dimension", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
|
||||
.select("tip", { mode: "all" });
|
||||
});
|
||||
|
||||
/*
|
||||
select modes: all, none, exact, range
|
||||
*/
|
||||
test("all", () => {
|
||||
expect(p.select("quantity", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
});
|
||||
test("none", () => {
|
||||
expect(p.select("quantity", { mode: "none" }).countSelected()).toEqual(0);
|
||||
});
|
||||
test.each([[[]], [[2]], [[2, 1]], [[9, 82]], [[0, 1]]])("exact: %p", v =>
|
||||
expect(
|
||||
p.select("quantity", { mode: "exact", values: v }).countSelected()
|
||||
).toEqual(_.filter(someData, d => v.includes(d.quantity)).length)
|
||||
);
|
||||
test.each([[0, 1], [1, 2], [0, 99], [99, 100000]])("range %p", (lo, hi) =>
|
||||
expect(
|
||||
p.select("quantity", { mode: "range", lo, hi }).countSelected()
|
||||
).toEqual(
|
||||
_.filter(someData, d => d.quantity >= lo && d.quantity < hi).length
|
||||
)
|
||||
);
|
||||
test("bad mode", () => {
|
||||
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enum dimension", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
p = payments.addDimension("type", "enum", (i, d) => d[i].type);
|
||||
});
|
||||
|
||||
test("all", () => {
|
||||
expect(p.select("type", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
});
|
||||
test("none", () => {
|
||||
expect(p.select("type", { mode: "none" }).countSelected()).toEqual(0);
|
||||
});
|
||||
test.each([
|
||||
[[]],
|
||||
[["tab"]],
|
||||
[["visa"]],
|
||||
[["visa", "tab"]],
|
||||
[["cash", "tab", "visa"]]
|
||||
])("exact: %p", v =>
|
||||
expect(
|
||||
p.select("type", { mode: "exact", values: v }).countSelected()
|
||||
).toEqual(_.filter(someData, d => v.includes(d.type)).length)
|
||||
);
|
||||
test("range", () => {
|
||||
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
|
||||
Error
|
||||
);
|
||||
});
|
||||
test("bad mode", () => {
|
||||
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spatial dimension", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
const X = someData.map(r => r.coords[0]);
|
||||
const Y = someData.map(r => r.coords[1]);
|
||||
p = payments.addDimension("coords", "spatial", X, Y);
|
||||
});
|
||||
|
||||
test("all", () => {
|
||||
expect(p.select("coords", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
});
|
||||
test("none", () => {
|
||||
expect(p.select("coords", { mode: "none" }).countSelected()).toEqual(0);
|
||||
});
|
||||
test.each([[0, 0, 1, 1], [0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]])(
|
||||
"within-rect %d %d %d %d",
|
||||
(x0, y0, x1, y1) => {
|
||||
expect(
|
||||
p
|
||||
.select("coords", { mode: "within-rect", x0, y0, x1, y1 })
|
||||
.allSelected()
|
||||
).toEqual(
|
||||
_.filter(someData, d => {
|
||||
const [x, y] = d.coords;
|
||||
return x0 <= x && x < x1 && y0 <= y && y < y1;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[[[0, 0], [0, 1], [1, 1], [1, 0]]],
|
||||
[[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]]]
|
||||
])("within-polygon %p", polygon => {
|
||||
expect(
|
||||
p.select("coords", { mode: "within-polygon", polygon }).allSelected()
|
||||
).toEqual(_.filter(someData, d => polygonContains(polygon, d.coords)));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,590 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import crossfilter from "../../../src/util/typedCrossfilter";
|
||||
|
||||
const someData = [
|
||||
{
|
||||
date: "2011-11-14T16:17:54Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:20:19Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:28:54Z",
|
||||
quantity: 1,
|
||||
total: 300,
|
||||
tip: 200,
|
||||
type: "visa",
|
||||
productIDs: ["004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:30:43Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:48:46Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:53:41Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:54:06Z",
|
||||
quantity: 1,
|
||||
total: 100,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["001", "002", "003", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:58:03Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:07:21Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:22:59Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:25:45Z",
|
||||
quantity: 2,
|
||||
total: 200,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["002"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:29:52Z",
|
||||
quantity: 1,
|
||||
total: 200,
|
||||
tip: 100,
|
||||
type: "visa",
|
||||
productIDs: ["004"]
|
||||
}
|
||||
];
|
||||
|
||||
function groupReduce(data, valueMap, valueReduce, valueInit) {
|
||||
return _.reduce(
|
||||
data,
|
||||
(acc, value) => {
|
||||
const k = valueMap(value);
|
||||
let r = _.find(acc, o => o.key === k);
|
||||
if (!r) {
|
||||
r = { key: k, value: valueInit() };
|
||||
acc.push(r);
|
||||
}
|
||||
r.value = valueReduce(r.value, value);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
||||
}
|
||||
|
||||
function groupCount(data, map) {
|
||||
return groupReduce(data, map, p => p + 1, () => 0);
|
||||
}
|
||||
|
||||
function groupSum(data, map) {
|
||||
return groupReduce(
|
||||
data,
|
||||
map,
|
||||
(p, v) => {
|
||||
p += map(v);
|
||||
return p;
|
||||
},
|
||||
() => 0
|
||||
);
|
||||
}
|
||||
|
||||
let payments = null;
|
||||
beforeEach(() => {
|
||||
payments = crossfilter(someData);
|
||||
});
|
||||
|
||||
describe("typedCrossfilter", () => {
|
||||
test("alloc and free", () => {
|
||||
expect(payments).toBeDefined();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
|
||||
const quantity = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].quantity,
|
||||
Int32Array
|
||||
);
|
||||
expect(quantity).toBeDefined();
|
||||
expect(quantity.id()).toBeDefined();
|
||||
|
||||
quantity.dispose();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("filterAll and filterNone", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].quantity,
|
||||
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(tip).toBeDefined();
|
||||
expect(total).toBeDefined();
|
||||
expect(type).toBeDefined();
|
||||
|
||||
// initially, all should be filtered
|
||||
expect(payments.allFiltered()).toHaveLength(payments.size());
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// filterAll
|
||||
tip.filterAll(); // should change nothing
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// ditto
|
||||
total.filterAll();
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// filterNone
|
||||
type.filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
quantity.filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
// invert the first none; should have no effect because type is
|
||||
// still not filtered
|
||||
quantity.filterAll();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
// filter all of type; should select all
|
||||
type.filterAll();
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(payments.size());
|
||||
});
|
||||
|
||||
test("filterExact", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].quantity,
|
||||
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);
|
||||
expect(payments.countFiltered()).toEqual(
|
||||
_.countBy(someData, "quantity")[1]
|
||||
);
|
||||
expect(payments.allFiltered()).toEqual(_.filter(someData, { quantity: 1 }));
|
||||
|
||||
tip.filterExact(0);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_.filter(someData, { tip: 0, quantity: 1 })
|
||||
);
|
||||
|
||||
type.filterExact("cash");
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_.filter(someData, { tip: 0, quantity: 1, type: "cash" })
|
||||
);
|
||||
});
|
||||
|
||||
test("filterRange", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].quantity,
|
||||
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]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 0 && r.tip < 91)
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterRange([0, 90]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 0 && r.tip < 90)
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterRange([1, 90]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 1 && r.tip < 91)
|
||||
.value()
|
||||
);
|
||||
});
|
||||
|
||||
test("filterEnum", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].quantity,
|
||||
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"]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.type === "cash" || r.type === "tab")
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterEnum([0, 100]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.type === "cash" || r.type === "tab")
|
||||
.filter(r => r.tip === 0 || r.tip === 100)
|
||||
.value()
|
||||
);
|
||||
});
|
||||
|
||||
test("more than 32 dimensions", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].quantity,
|
||||
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
|
||||
let dimMap = {};
|
||||
for (let i = 0; i < 65; i++) {
|
||||
dimMap[i] = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
() => Math.random(),
|
||||
Float32Array
|
||||
);
|
||||
expect(dimMap[i]).toBeDefined();
|
||||
expect(dimMap[i].id()).toBeDefined();
|
||||
}
|
||||
|
||||
// everything should start as selected/filtered
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
dimMap[0].filterAll();
|
||||
dimMap[64].filterAll();
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
dimMap[33].filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
|
||||
dimMap[33].filterAll();
|
||||
expect(payments.allFiltered()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("group, default mapping, default reducer, no filter", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
const quantity = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].quantity,
|
||||
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(
|
||||
{
|
||||
tip: tip.group(r => r),
|
||||
type: type.group(),
|
||||
total: total.group(),
|
||||
quantity: quantity.group()
|
||||
},
|
||||
(grp, k) => {
|
||||
const whatWeExpect = groupCount(someData, v => v[k]);
|
||||
expect(grp.all()).toEqual(whatWeExpect);
|
||||
expect(grp.size()).toEqual(whatWeExpect.length);
|
||||
expect(grp.dispose()).toEqual(grp);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("group, custom map, default reducer, no filters", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
// custom mapping in groups only works for scalar types. Enums do not
|
||||
// currently implement it.
|
||||
|
||||
const tip = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(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_B = tip.group(r => 10 * r);
|
||||
const paymentsByType = type.group(); // identity only
|
||||
const paymentsByTotalX10_A = totalX10.group();
|
||||
const paymentsByTotalX10_B = totalX10.group(r => r / 10);
|
||||
|
||||
expect(paymentsByTip_A.all()).toEqual(groupCount(someData, v => v.tip));
|
||||
expect(paymentsByTip_B.all()).toEqual(
|
||||
groupCount(someData, v => 10 * v.tip)
|
||||
);
|
||||
expect(paymentsByType.all()).toEqual(groupCount(someData, v => v.type));
|
||||
expect(paymentsByTotalX10_A.all()).toEqual(
|
||||
groupCount(someData, v => 10 * v.total)
|
||||
);
|
||||
expect(paymentsByTotalX10_B.all()).toEqual(
|
||||
groupCount(someData, v => (10 * v.total) / 10)
|
||||
);
|
||||
|
||||
for (let i of [
|
||||
paymentsByTip_A,
|
||||
paymentsByTip_B,
|
||||
paymentsByType,
|
||||
paymentsByTotalX10_A,
|
||||
paymentsByTotalX10_B,
|
||||
tip,
|
||||
totalX10,
|
||||
type
|
||||
]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("group, default map, custom reducer, no filters", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
const total = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(i, data) => data[i].total,
|
||||
Float32Array
|
||||
);
|
||||
const type = payments.dimension(
|
||||
crossfilter.EnumDimension,
|
||||
(i, data) => data[i].type
|
||||
);
|
||||
|
||||
const paymentsByTotal = total.group();
|
||||
const paymentsByType = type.group();
|
||||
|
||||
// reduceCount
|
||||
expect(paymentsByTotal.reduceCount()).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(groupCount(someData, v => v.total));
|
||||
|
||||
// reduceSum
|
||||
expect(paymentsByTotal.reduceSum(v => v.total)).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(groupSum(someData, v => v.total));
|
||||
|
||||
// use custom reducers (my reducers) - count by three, init 1
|
||||
expect(
|
||||
paymentsByTotal.reduce((p, v) => (p += 3), (p, v) => (p -= 3), () => 1)
|
||||
).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(
|
||||
groupReduce(someData, v => v.total, (p, v) => p + 3, () => 1)
|
||||
);
|
||||
|
||||
for (let i of [paymentsByTotal, paymentsByType, type]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("group, default map, default reducer, filters", () => {
|
||||
// From the docs:
|
||||
// Note: a grouping intersects the crossfilter's current filters, except for the
|
||||
// associated dimension's filter. Thus, group methods consider only records that
|
||||
// satisfy every filter except this dimension's filter. So, if the crossfilter of
|
||||
// payments is filtered by type and total, then group by total only observes the
|
||||
// filter by type.
|
||||
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
const tip = payments.dimension(
|
||||
crossfilter.ScalarDimension,
|
||||
(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 paymentsByTotal = total.group();
|
||||
const paymentsByType = type.group();
|
||||
|
||||
// 1. confirm that changing the filter on a dimension does NOT change that
|
||||
// dimensions groups.
|
||||
{
|
||||
tip.filterAll(), total.filterAll(), type.filterAll();
|
||||
let before = _.cloneDeep(paymentsByTip.all());
|
||||
tip.filterExact(0);
|
||||
expect(paymentsByTip.all()).toEqual(before);
|
||||
}
|
||||
|
||||
// 2. confirm that changing a filter on a different dimension DOES change
|
||||
// all other groups.
|
||||
{
|
||||
tip.filterAll(), total.filterAll(), type.filterAll();
|
||||
const before = _.cloneDeep([paymentsByTotal.all(), paymentsByType.all()]);
|
||||
tip.filterExact(0);
|
||||
const after = [paymentsByTotal.all(), paymentsByType.all()];
|
||||
expect(after).not.toEqual(before);
|
||||
expect(after).toEqual([
|
||||
groupReduce(
|
||||
someData,
|
||||
v => v.total,
|
||||
(p, v) => (v.tip !== 0 ? p : p + 1),
|
||||
() => 0
|
||||
),
|
||||
groupReduce(
|
||||
someData,
|
||||
v => v.type,
|
||||
(p, v) => (v.tip !== 0 ? p : p + 1),
|
||||
() => 0
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
for (let i of [
|
||||
paymentsByTip,
|
||||
paymentsByTotal,
|
||||
paymentsByType,
|
||||
tip,
|
||||
total,
|
||||
type
|
||||
]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ module.exports = {
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from"
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ module.exports = {
|
||||
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-transform-react-constant-elements",
|
||||
"@babel/plugin-transform-runtime"
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
};
|
||||
|
||||
@@ -77,6 +77,9 @@ module.exports = {
|
||||
template: path.resolve("index.html"),
|
||||
favicon: path.resolve("favicon.png")
|
||||
}),
|
||||
new webpack.NoEmitOnErrorsPlugin()
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })"
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
Generated
+383
-31
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -443,6 +443,16 @@
|
||||
"@babel/plugin-syntax-json-strings": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.2.0.tgz",
|
||||
"integrity": "sha512-QXj/YjFuFJd68oDvoc1e8aqLr2wz7Kofzvp6Ekd/o7MWZl+nZ0/cpStxND+hlZ7DpRWAp7OmuyT2areZ2V3YUA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.0.0",
|
||||
"@babel/plugin-syntax-nullish-coalescing-operator": "^7.2.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-object-rest-spread": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.0.0.tgz",
|
||||
@@ -463,6 +473,16 @@
|
||||
"@babel/plugin-syntax-optional-catch-binding": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-optional-chaining": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.2.0.tgz",
|
||||
"integrity": "sha512-ea3Q6edZC/55wEBVZAEz42v528VulyO0eir+7uky/sT4XRcdkWJcFi1aPtitTlwUzGnECWJNExWww1SStt+yWw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.0.0",
|
||||
"@babel/plugin-syntax-optional-chaining": "^7.2.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-unicode-property-regex": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.0.0.tgz",
|
||||
@@ -537,6 +557,15 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-nullish-coalescing-operator": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.2.0.tgz",
|
||||
"integrity": "sha512-lRCEaKE+LTxDQtgbYajI04ddt6WW0WJq57xqkAZ+s11h4YgfRHhVA/Y2VhfPzzFD4qeLHWg32DMp9HooY4Kqlg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-object-rest-spread": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.0.0.tgz",
|
||||
@@ -555,6 +584,15 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-optional-chaining": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz",
|
||||
"integrity": "sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-arrow-functions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.0.0.tgz",
|
||||
@@ -3078,6 +3116,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"clone-deep": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz",
|
||||
"integrity": "sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"for-own": "^0.1.3",
|
||||
"is-plain-object": "^2.0.1",
|
||||
"kind-of": "^3.0.2",
|
||||
"lazy-cache": "^1.0.3",
|
||||
"shallow-clone": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -3509,6 +3560,16 @@
|
||||
"array-find-index": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"cwd": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/cwd/-/cwd-0.10.0.tgz",
|
||||
"integrity": "sha1-FyQAaUBXwioTsM8WFix+S3p/5Wc=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"find-pkg": "^0.1.2",
|
||||
"fs-exists-sync": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"cyclist": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz",
|
||||
@@ -4893,6 +4954,15 @@
|
||||
"fill-range": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"expand-tilde": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-1.2.2.tgz",
|
||||
"integrity": "sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"os-homedir": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"version": "24.1.0",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-24.1.0.tgz",
|
||||
@@ -4906,6 +4976,12 @@
|
||||
"jest-regex-util": "^24.0.0"
|
||||
}
|
||||
},
|
||||
"expect-puppeteer": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-puppeteer/-/expect-puppeteer-4.1.0.tgz",
|
||||
"integrity": "sha512-X6hn3xujENCtwCZL73qqqfNZFgJZSAHEd4kfx5gpYkkyXf8ltIhu2+Bj8Cu4akSW1izrwoXWQ0YEqqMhgC7K7Q==",
|
||||
"dev": true
|
||||
},
|
||||
"express": {
|
||||
"version": "4.16.4",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz",
|
||||
@@ -5218,6 +5294,54 @@
|
||||
"pkg-dir": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"find-file-up": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/find-file-up/-/find-file-up-0.1.3.tgz",
|
||||
"integrity": "sha1-z2gJG8+fMApA2kEbN9pczlovvqA=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs-exists-sync": "^0.1.0",
|
||||
"resolve-dir": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"find-pkg": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/find-pkg/-/find-pkg-0.1.2.tgz",
|
||||
"integrity": "sha1-G9wiwG42NlUy4qJIBGhUuXiNpVc=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"find-file-up": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"find-process": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/find-process/-/find-process-1.2.1.tgz",
|
||||
"integrity": "sha512-z4RXYStNAcoi4+smpKbzJXbMT8DdvwqTE7wL7DWZMD0SkTRfQ49z9S7YaK24kuRseKr23YSZlnyL/TaJZtgM1g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^2.0.1",
|
||||
"commander": "^2.11.0",
|
||||
"debug": "^2.6.8",
|
||||
"lodash": "^4.17.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"find-up": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
|
||||
@@ -5328,6 +5452,12 @@
|
||||
"readable-stream": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"fs-exists-sync": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz",
|
||||
"integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=",
|
||||
"dev": true
|
||||
},
|
||||
"fs-write-stream-atomic": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
|
||||
@@ -5366,8 +5496,7 @@
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -5388,14 +5517,12 @@
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -5410,20 +5537,17 @@
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -5540,8 +5664,7 @@
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
@@ -5553,7 +5676,6 @@
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -5568,7 +5690,6 @@
|
||||
"version": "3.0.4",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
@@ -5576,14 +5697,12 @@
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.2.4",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.1",
|
||||
"yallist": "^3.0.0"
|
||||
@@ -5602,7 +5721,6 @@
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
@@ -5683,8 +5801,7 @@
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -5696,7 +5813,6 @@
|
||||
"version": "1.4.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -5782,8 +5898,7 @@
|
||||
"safe-buffer": {
|
||||
"version": "5.1.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -5819,7 +5934,6 @@
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -5839,7 +5953,6 @@
|
||||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -5883,14 +5996,12 @@
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5996,12 +6107,50 @@
|
||||
"ini": "^1.3.4"
|
||||
}
|
||||
},
|
||||
"global-modules": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-0.2.3.tgz",
|
||||
"integrity": "sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"global-prefix": "^0.1.4",
|
||||
"is-windows": "^0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-windows": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-0.2.0.tgz",
|
||||
"integrity": "sha1-3hqm1j6indJIc3tp8f+LgALSEIw=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"global-modules-path": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/global-modules-path/-/global-modules-path-2.3.0.tgz",
|
||||
"integrity": "sha512-HchvMJNYh9dGSCy8pOQ2O8u/hoXaL+0XhnrwH0RyLiSXMMTl9W3N6KUU73+JFOg5PGjtzl6VZzUQsnrpm7Szag==",
|
||||
"dev": true
|
||||
},
|
||||
"global-prefix": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-0.1.5.tgz",
|
||||
"integrity": "sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"homedir-polyfill": "^1.0.0",
|
||||
"ini": "^1.3.4",
|
||||
"is-windows": "^0.2.0",
|
||||
"which": "^1.2.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-windows": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-0.2.0.tgz",
|
||||
"integrity": "sha1-3hqm1j6indJIc3tp8f+LgALSEIw=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"version": "11.8.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.8.0.tgz",
|
||||
@@ -6255,6 +6404,15 @@
|
||||
"integrity": "sha1-V6j+JM8zzdUkhgoVgh3cJchmcfs=",
|
||||
"dev": true
|
||||
},
|
||||
"homedir-polyfill": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
|
||||
"integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"parse-passwd": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"hosted-git-info": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz",
|
||||
@@ -7939,6 +8097,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"jest-dev-server": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-dev-server/-/jest-dev-server-4.0.0.tgz",
|
||||
"integrity": "sha512-tq3fHPM8BDbu/71yIxgGgZW62s1Em6rLNDce0/ff/4No093OyjUEPM8yIUaoBt4pxwwRGkaS1EZB5PzCmRLGkg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^2.4.2",
|
||||
"cwd": "^0.10.0",
|
||||
"find-process": "^1.2.1",
|
||||
"inquirer": "^6.2.2",
|
||||
"spawnd": "^4.0.0",
|
||||
"tree-kill": "^1.2.1",
|
||||
"wait-port": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"jest-diff": {
|
||||
"version": "24.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.0.0.tgz",
|
||||
@@ -7993,6 +8166,18 @@
|
||||
"jest-util": "^24.0.0"
|
||||
}
|
||||
},
|
||||
"jest-environment-puppeteer": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-environment-puppeteer/-/jest-environment-puppeteer-4.1.0.tgz",
|
||||
"integrity": "sha512-Usq/T0W+BcnWZ59Hyrs7KA2917NDJt+navI9hTv96CspEkyLef3TaWtmL84EXmY14C0fBa+r2efwLgtrRwPAcg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^2.4.2",
|
||||
"cwd": "^0.10.0",
|
||||
"jest-dev-server": "^4.0.0",
|
||||
"merge-deep": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"jest-get-type": {
|
||||
"version": "24.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.0.0.tgz",
|
||||
@@ -8669,6 +8854,16 @@
|
||||
"integrity": "sha512-sQp0Hu5fcf5NZEh1U9eIW2qD0BwJZjb63Yqd98PQJFvf/zzUTBoUAwv/Dc/HFeNHIw1f3hl/48vNn+j3STaI7A==",
|
||||
"dev": true
|
||||
},
|
||||
"jest-puppeteer": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-puppeteer/-/jest-puppeteer-4.1.0.tgz",
|
||||
"integrity": "sha512-dCHU2XbrxuykPa38x5fixN/KS/zO2OIYZeORtuf0SvuIAPPbCfj/RBfVV55FCuPkp+PmAEJJjnIF0rVBe5B2qg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"expect-puppeteer": "^4.1.0",
|
||||
"jest-environment-puppeteer": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"jest-regex-util": {
|
||||
"version": "24.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.0.0.tgz",
|
||||
@@ -9390,6 +9585,12 @@
|
||||
"integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=",
|
||||
"dev": true
|
||||
},
|
||||
"lazy-cache": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
|
||||
"integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=",
|
||||
"dev": true
|
||||
},
|
||||
"lcid": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz",
|
||||
@@ -9775,6 +9976,17 @@
|
||||
"integrity": "sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==",
|
||||
"dev": true
|
||||
},
|
||||
"merge-deep": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.2.tgz",
|
||||
"integrity": "sha512-T7qC8kg4Zoti1cFd8Cr0M+qaZfOwjlPDEdZIIPPB2JZctjaPM4fX+i7HOId69tAti2fvO6X5ldfYUONDODsrkA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"arr-union": "^3.1.0",
|
||||
"clone-deep": "^0.2.4",
|
||||
"kind-of": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"merge-descriptors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||
@@ -9931,6 +10143,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mixin-object": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz",
|
||||
"integrity": "sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"for-in": "^0.1.3",
|
||||
"is-extendable": "^0.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"for-in": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz",
|
||||
"integrity": "sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
@@ -10407,6 +10637,12 @@
|
||||
"integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=",
|
||||
"dev": true
|
||||
},
|
||||
"os-homedir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
|
||||
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
|
||||
"dev": true
|
||||
},
|
||||
"os-locale": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz",
|
||||
@@ -10604,6 +10840,12 @@
|
||||
"error-ex": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"parse-passwd": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
|
||||
"integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=",
|
||||
"dev": true
|
||||
},
|
||||
"parse5": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz",
|
||||
@@ -11969,6 +12211,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolve-dir": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-0.1.1.tgz",
|
||||
"integrity": "sha1-shklmlYC+sXFxJatiUpujMQwJh4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"expand-tilde": "^1.2.2",
|
||||
"global-modules": "^0.2.3"
|
||||
}
|
||||
},
|
||||
"resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -12612,6 +12864,35 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"shallow-clone": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz",
|
||||
"integrity": "sha1-WQnodLp3EG1zrEFM/sH/yofZcGA=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-extendable": "^0.1.1",
|
||||
"kind-of": "^2.0.1",
|
||||
"lazy-cache": "^0.2.3",
|
||||
"mixin-object": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"kind-of": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz",
|
||||
"integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-buffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"lazy-cache": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz",
|
||||
"integrity": "sha1-f+3fLctu23fRHvHRF6tf/fCrG2U=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
@@ -12839,6 +13120,18 @@
|
||||
"integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=",
|
||||
"dev": true
|
||||
},
|
||||
"spawnd": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/spawnd/-/spawnd-4.0.0.tgz",
|
||||
"integrity": "sha512-ql3qhJnhAkvXpaqKBWOqou1rUTSQhFRaZkyOT+MTFB4xY3X+brgw6LTWV2wHuE9A6YPhrNe1cbg7S+jAYnbC0Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"exit": "^0.1.2",
|
||||
"signal-exit": "^3.0.2",
|
||||
"tree-kill": "^1.2.1",
|
||||
"wait-port": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"spdx-correct": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.2.tgz",
|
||||
@@ -13525,6 +13818,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"tree-kill": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.1.tgz",
|
||||
"integrity": "sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q==",
|
||||
"dev": true
|
||||
},
|
||||
"trim-newlines": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
|
||||
@@ -14042,6 +14341,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wait-port": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/wait-port/-/wait-port-0.2.2.tgz",
|
||||
"integrity": "sha1-1RpJHkhKF791qUfnEaLwErTm8uM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^1.1.3",
|
||||
"commander": "^2.9.0",
|
||||
"debug": "^2.6.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
|
||||
"integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=",
|
||||
"dev": true
|
||||
},
|
||||
"chalk": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
|
||||
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^2.2.1",
|
||||
"escape-string-regexp": "^1.0.2",
|
||||
"has-ansi": "^2.0.0",
|
||||
"strip-ansi": "^3.0.0",
|
||||
"supports-color": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"dev": true
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
|
||||
"integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"walker": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"license": "MIT",
|
||||
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
||||
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||
@@ -9,11 +9,12 @@
|
||||
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
|
||||
"clean": "rimraf build",
|
||||
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
|
||||
"e2e": "jest e2e",
|
||||
"e2e": "jest --verbose false --config __tests__/e2e/e2eJestConfig.json e2e",
|
||||
"lint": "eslint src",
|
||||
"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",
|
||||
"test": "jest",
|
||||
"unit-test": "jest --testPathIgnorePatterns e2e"
|
||||
},
|
||||
"engineStrict": true,
|
||||
@@ -69,6 +70,8 @@
|
||||
"@babel/plugin-proposal-decorators": "^7.0.0",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
|
||||
"@babel/plugin-proposal-function-bind": "^7.0.0",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.2.0",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
|
||||
"@babel/plugin-transform-runtime": "^7.1.0",
|
||||
"@babel/preset-env": "^7.1.5",
|
||||
@@ -98,6 +101,7 @@
|
||||
"html-webpack-inline-source-plugin": "0.0.10",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"jest": "^24.1.0",
|
||||
"jest-puppeteer": "^4.1.0",
|
||||
"json-loader": "^0.5.4",
|
||||
"mini-css-extract-plugin": "^0.4.1",
|
||||
"puppeteer": "^1.12.1",
|
||||
|
||||
@@ -65,7 +65,7 @@ Set the view (world) to current selection. Placeholder for an async action
|
||||
which also does re-layout.
|
||||
*/
|
||||
const regraph = () => (dispatch, getState) => {
|
||||
const { universe, world, crossfilter } = getState().controls;
|
||||
const { universe, world, crossfilter } = getState();
|
||||
dispatch({
|
||||
type: "set World to current selection",
|
||||
universe,
|
||||
@@ -124,7 +124,7 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||
};
|
||||
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
const { universe } = state;
|
||||
/* preload data already in cache */
|
||||
let expressionData = _.transform(
|
||||
genes,
|
||||
@@ -164,7 +164,7 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||
dispatch({ type: "get single gene expression for coloring started" });
|
||||
try {
|
||||
await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
const { world } = getState();
|
||||
dispatch({
|
||||
type: "color by expression",
|
||||
gene,
|
||||
@@ -185,7 +185,7 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => {
|
||||
dispatch({ type: "request user defined gene started" });
|
||||
try {
|
||||
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
const { world } = getState();
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
@@ -240,7 +240,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
2. get expression data for each
|
||||
*/
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
const { universe } = state;
|
||||
|
||||
// Legal values are null, Array or TypedArray. Null is initial state.
|
||||
if (!set1) set1 = [];
|
||||
@@ -300,7 +300,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
};
|
||||
|
||||
const resetInterface = () => (dispatch, getState) => {
|
||||
const { universe } = getState().controls;
|
||||
const { universe } = getState();
|
||||
|
||||
dispatch({
|
||||
type: "clear all user defined genes"
|
||||
|
||||
@@ -13,16 +13,16 @@ import memoize from "memoize-one";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import finiteExtent from "../../util/finiteExtent";
|
||||
import { makeContinuousDimensionName } from "../../util/nameCreators";
|
||||
|
||||
@connect(state => ({
|
||||
world: state.controls.world,
|
||||
world: state.world,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
continuousSelection: state.continuousSelection,
|
||||
differential: state.differential,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null)
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
obsAnnotations: _.get(state.world, "obsAnnotations", null)
|
||||
}))
|
||||
class HistogramBrush extends React.Component {
|
||||
calcHistogramCache = memoize((obsAnnotations, field, rangeMin, rangeMax) => {
|
||||
@@ -87,21 +87,65 @@ class HistogramBrush extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { field, obsAnnotations } = this.props;
|
||||
const { field, obsAnnotations, continuousSelection } = this.props;
|
||||
const { x, y, bins, numValues, svgRef } = this._histogram;
|
||||
|
||||
if (obsAnnotations !== prevProps.obsAnnotations) {
|
||||
this.renderAxesBrushBins(x, y, bins, numValues, svgRef, field);
|
||||
}
|
||||
|
||||
/*
|
||||
if the selection has changed, ensure that the brush correctly reflects
|
||||
the underlying selection.
|
||||
*/
|
||||
if (continuousSelection !== prevProps.continuousSelection) {
|
||||
const { isObs, isUserDefined, isDiffExp } = this.props;
|
||||
const myName = makeContinuousDimensionName(
|
||||
{ isObs, isUserDefined, isDiffExp },
|
||||
field
|
||||
);
|
||||
const range = continuousSelection[myName];
|
||||
const { brushXselection, brushX } = this.state;
|
||||
if (brushXselection) {
|
||||
const selection = d3.brushSelection(brushXselection.node());
|
||||
if (!range && selection) {
|
||||
/* no active selection - clear brush */
|
||||
brushXselection.call(brushX.move, null);
|
||||
} else if (range && !selection) {
|
||||
/* there is an active selection, but no brush - set the brush */
|
||||
const x0 = x(range[0]);
|
||||
const x1 = x(range[1]);
|
||||
brushXselection.call(brushX.move, [x0, x1]);
|
||||
} else if (range && selection) {
|
||||
/* there is an active selection and a brush - make sure they match */
|
||||
const moveDeltaThreshold = 1;
|
||||
const x0 = x(range[0]);
|
||||
const x1 = x(range[1]);
|
||||
const dX0 = Math.abs(x0 - selection[0]);
|
||||
const dX1 = Math.abs(x1 - selection[1]);
|
||||
/*
|
||||
only update the brush if it is grossly incorrect,
|
||||
as defined by the moveDeltaThreshold
|
||||
*/
|
||||
if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) {
|
||||
brushXselection.call(brushX.move, [x0, x1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBrush(selection, x) {
|
||||
onBrush(selection, x, eventType) {
|
||||
const type = `continuous metadata histogram ${eventType}`;
|
||||
return () => {
|
||||
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||
|
||||
// ignore programmatically generated events
|
||||
if (!d3.event.sourceEvent) return;
|
||||
|
||||
if (d3.event.selection) {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -112,7 +156,67 @@ class HistogramBrush extends React.Component {
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
isUserDefined,
|
||||
isDiffExp
|
||||
},
|
||||
range: null
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onBrushEnd(selection, x) {
|
||||
return () => {
|
||||
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||
const { brushXselection } = this.state;
|
||||
const minAllowedBrushSize = 10;
|
||||
const smallAmountToAvoidInfiniteLoop = 0.1;
|
||||
|
||||
// ignore programmatically generated events
|
||||
if (!d3.event.sourceEvent) return;
|
||||
|
||||
if (d3.event.selection) {
|
||||
let _range;
|
||||
|
||||
if (
|
||||
d3.event.selection[1] - d3.event.selection[0] >
|
||||
minAllowedBrushSize
|
||||
) {
|
||||
_range = [x(d3.event.selection[0]), x(d3.event.selection[1])];
|
||||
} else {
|
||||
/* the user selected range is too small and will be hidden #587, so take control of it procedurally */
|
||||
/* https://stackoverflow.com/questions/12354729/d3-js-limit-size-of-brush */
|
||||
|
||||
const procedurallyResizedBrushWidth =
|
||||
d3.event.selection[0] +
|
||||
minAllowedBrushSize +
|
||||
smallAmountToAvoidInfiniteLoop; //
|
||||
|
||||
_range = [x(d3.event.selection[0]), x(procedurallyResizedBrushWidth)];
|
||||
|
||||
d3.event.target.move(brushXselection, [
|
||||
d3.event.selection[0],
|
||||
procedurallyResizedBrushWidth
|
||||
]);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "continuous metadata histogram end",
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
isUserDefined,
|
||||
isDiffExp
|
||||
},
|
||||
range: _range
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram end",
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -225,16 +329,21 @@ class HistogramBrush extends React.Component {
|
||||
.attr("height", d => y(0) - y(d.length / numValues));
|
||||
|
||||
/* BRUSH */
|
||||
d3.select(svgRef)
|
||||
const brushX = d3
|
||||
.brushX()
|
||||
/*
|
||||
emit start so that the Undoable history can save an undo point
|
||||
upon drag start, and ignore the subsequent intermediate drag events.
|
||||
*/
|
||||
.on("start", this.onBrush(field, x.invert, "start").bind(this))
|
||||
.on("brush", this.onBrush(field, x.invert, "brush").bind(this))
|
||||
.on("end", this.onBrushEnd(field, x.invert).bind(this));
|
||||
const brushXselection = d3
|
||||
.select(svgRef)
|
||||
.append("g")
|
||||
.attr("class", "brush")
|
||||
.attr("data-testid", `${svgRef.id}-brush`)
|
||||
.call(
|
||||
d3
|
||||
.brushX()
|
||||
.on("brush", this.onBrush(field, x.invert).bind(this))
|
||||
.on("end", this.onBrush(field, x.invert).bind(this))
|
||||
);
|
||||
.attr("data-testid", `${svgRef.dataset.testid}-brush`)
|
||||
.call(brushX);
|
||||
|
||||
/* AXIS */
|
||||
d3.select(svgRef)
|
||||
@@ -254,6 +363,8 @@ class HistogramBrush extends React.Component {
|
||||
d3.select(svgRef)
|
||||
.selectAll(".axis--x line")
|
||||
.style("stroke", "rgb(230,230,230)");
|
||||
|
||||
this.setState({ brushX, brushXselection });
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -269,12 +380,18 @@ class HistogramBrush extends React.Component {
|
||||
scatterplotYYaccessor,
|
||||
zebra
|
||||
} = this.props;
|
||||
|
||||
const field_for_id = field.replace(/\s/g, "_");
|
||||
return (
|
||||
<div
|
||||
id={`histogram_${field}`}
|
||||
id={`histogram_${field_for_id}`}
|
||||
data-testid={`histogram-${field}`}
|
||||
data-testclass={isDiffExp ? `histogram-diffexp` : ""}
|
||||
data-testclass={
|
||||
isDiffExp
|
||||
? "histogram-diffexp"
|
||||
: isUserDefined
|
||||
? "histogram-user-gene"
|
||||
: "histogram-continuous-metadata"
|
||||
}
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding,
|
||||
backgroundColor: zebra ? globals.lightestGrey : "white"
|
||||
@@ -289,6 +406,7 @@ class HistogramBrush extends React.Component {
|
||||
/>
|
||||
<ButtonGroup style={{ marginRight: 7 }}>
|
||||
<Button
|
||||
data-testid={`plot-x-${field}`}
|
||||
onClick={this.handleSetGeneAsScatterplotX(field).bind(this)}
|
||||
active={scatterplotXXaccessor === field}
|
||||
intent={scatterplotXXaccessor === field ? "primary" : "none"}
|
||||
@@ -296,6 +414,7 @@ class HistogramBrush extends React.Component {
|
||||
plot x
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`plot-y-${field}`}
|
||||
onClick={this.handleSetGeneAsScatterplotY(field).bind(this)}
|
||||
active={scatterplotYYaccessor === field}
|
||||
intent={scatterplotYYaccessor === field ? "primary" : "none"}
|
||||
@@ -323,6 +442,8 @@ class HistogramBrush extends React.Component {
|
||||
onClick={this.handleColorAction.bind(this)}
|
||||
active={colorAccessor === field}
|
||||
intent={colorAccessor === field ? "primary" : "none"}
|
||||
data-testclass="colorby"
|
||||
data-testid={`colorby-${field}`}
|
||||
icon="tint"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -330,7 +451,9 @@ class HistogramBrush extends React.Component {
|
||||
<svg
|
||||
width={this.width}
|
||||
height={this.height}
|
||||
id={`histogram_${field}_svg`}
|
||||
id={`histogram_${field_for_id}_svg`}
|
||||
data-testclass="histogram-plot"
|
||||
data-testid={`histogram-${field}-plot`}
|
||||
ref={svgRef => {
|
||||
this.drawHistogram(svgRef);
|
||||
}}
|
||||
@@ -341,7 +464,12 @@ class HistogramBrush extends React.Component {
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<span style={{ fontStyle: "italic" }}>{field}</span>
|
||||
<span
|
||||
data-testclass="brushable-histogram-field-name"
|
||||
style={{ fontStyle: "italic" }}
|
||||
>
|
||||
{field}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isDiffExp ? (
|
||||
|
||||
@@ -6,12 +6,12 @@ import * as globals from "../../globals";
|
||||
import Category from "./category";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||
categoricalSelection: state.categoricalSelection
|
||||
}))
|
||||
class Categories extends React.Component {
|
||||
render() {
|
||||
const { categoricalSelectionState } = this.props;
|
||||
if (!categoricalSelectionState) return null;
|
||||
const { categoricalSelection } = this.props;
|
||||
if (!categoricalSelection) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -26,7 +26,7 @@ class Categories extends React.Component {
|
||||
>
|
||||
Categorical Metadata
|
||||
</p>
|
||||
{_.map(categoricalSelectionState, (catState, catName) => (
|
||||
{_.map(categoricalSelection, (catState, catName) => (
|
||||
<Category key={catName} metadataField={catName} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,8 @@ import Value from "./value";
|
||||
import sortedCategoryValues from "./util";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection
|
||||
}))
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -21,38 +21,43 @@ class Category extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { categoricalSelectionState, metadataField } = this.props;
|
||||
const cat = categoricalSelectionState[metadataField];
|
||||
const categoryCount = {
|
||||
// total number of categories in this dimension
|
||||
totalCatCount: cat.numCategories,
|
||||
// number of selected options in this category
|
||||
selectedCatCount: _.reduce(
|
||||
cat.categorySelected,
|
||||
(res, cond) => (cond ? res + 1 : res),
|
||||
0
|
||||
)
|
||||
};
|
||||
if (categoryCount.selectedCatCount === categoryCount.totalCatCount) {
|
||||
/* everything is on, so not indeterminate */
|
||||
this.checkbox.indeterminate = false;
|
||||
} else if (categoryCount.selectedCatCount === 0) {
|
||||
/* nothing is on, so no */
|
||||
this.checkbox.indeterminate = false;
|
||||
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) {
|
||||
/* to be explicit... */
|
||||
this.checkbox.indeterminate = true;
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField } = this.props;
|
||||
if (categoricalSelection !== prevProps.categoricalSelection) {
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const categoryCount = {
|
||||
// total number of categories in this dimension
|
||||
totalCatCount: cat.numCategories,
|
||||
// number of selected options in this category
|
||||
selectedCatCount: _.reduce(
|
||||
cat.categorySelected,
|
||||
(res, cond) => (cond ? res + 1 : res),
|
||||
0
|
||||
)
|
||||
};
|
||||
if (categoryCount.selectedCatCount === categoryCount.totalCatCount) {
|
||||
/* everything is on, so not indeterminate */
|
||||
this.checkbox.indeterminate = false;
|
||||
this.setState({ isChecked: true }); // eslint-disable-line react/no-did-update-set-state
|
||||
} else if (categoryCount.selectedCatCount === 0) {
|
||||
/* nothing is on, so no */
|
||||
this.checkbox.indeterminate = false;
|
||||
this.setState({ isChecked: false }); // eslint-disable-line react/no-did-update-set-state
|
||||
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) {
|
||||
/* to be explicit... */
|
||||
this.checkbox.indeterminate = true;
|
||||
this.setState({ isChecked: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleColorChange() {
|
||||
handleColorChange = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "color by categorical metadata",
|
||||
colorAccessor: metadataField
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleAll() {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
@@ -83,9 +88,9 @@ class Category extends React.Component {
|
||||
}
|
||||
|
||||
renderCategoryItems() {
|
||||
const { categoricalSelectionState, metadataField } = this.props;
|
||||
const { categoricalSelection, metadataField } = this.props;
|
||||
|
||||
const cat = categoricalSelectionState[metadataField];
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const optTuples = sortedCategoryValues([...cat.categoryIndices]);
|
||||
return _.map(optTuples, (tuple, i) => (
|
||||
<Value
|
||||
@@ -100,17 +105,15 @@ class Category extends React.Component {
|
||||
|
||||
render() {
|
||||
const { isExpanded, isChecked } = this.state;
|
||||
const {
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
categoricalSelectionState
|
||||
} = this.props;
|
||||
const { isTruncated } = categoricalSelectionState[metadataField];
|
||||
const { metadataField, colorAccessor, categoricalSelection } = this.props;
|
||||
const { isTruncated } = categoricalSelection[metadataField];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: globals.maxControlsWidth
|
||||
}}
|
||||
data-testclass="category"
|
||||
data-testid={`category-${metadataField}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -128,6 +131,8 @@ class Category extends React.Component {
|
||||
>
|
||||
<label className="bp3-control bp3-checkbox">
|
||||
<input
|
||||
data-testclass="category-select"
|
||||
data-testid={`category-select-${metadataField}`}
|
||||
onChange={this.handleToggleAllClick.bind(this)}
|
||||
ref={el => {
|
||||
this.checkbox = el;
|
||||
@@ -141,6 +146,7 @@ class Category extends React.Component {
|
||||
</label>
|
||||
|
||||
<span
|
||||
data-testid={`category-expand-${metadataField}`}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block"
|
||||
@@ -151,18 +157,26 @@ class Category extends React.Component {
|
||||
>
|
||||
{metadataField}
|
||||
{isExpanded ? (
|
||||
<FaChevronDown style={{ fontSize: 10, marginLeft: 5 }} />
|
||||
<FaChevronDown
|
||||
data-testclass="category-expand-is-expanded"
|
||||
style={{ fontSize: 10, marginLeft: 5 }}
|
||||
/>
|
||||
) : (
|
||||
<FaChevronRight style={{ fontSize: 10, marginLeft: 5 }} />
|
||||
<FaChevronRight
|
||||
data-testclass="category-expand-is-not-expanded"
|
||||
style={{ fontSize: 10, marginLeft: 5 }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip content="Use as color scale" position="bottom">
|
||||
<Button
|
||||
onClick={this.handleColorChange.bind(this)}
|
||||
data-testclass="colorby"
|
||||
data-testid={`colorby-${metadataField}`}
|
||||
onClick={this.handleColorChange}
|
||||
active={colorAccessor === metadataField}
|
||||
intent={colorAccessor === metadataField ? "primary" : "none"}
|
||||
icon={"tint"}
|
||||
icon="tint"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ class Occupancy extends React.Component {
|
||||
const {
|
||||
occupancy,
|
||||
colorScale,
|
||||
categoricalSelectionState,
|
||||
categoricalSelection,
|
||||
colorAccessor,
|
||||
schema
|
||||
} = this.props;
|
||||
@@ -29,23 +29,21 @@ class Occupancy extends React.Component {
|
||||
|
||||
let currentOffset = 0;
|
||||
|
||||
const stacks = categoricalSelectionState[colorAccessor].categoryValues.map(
|
||||
d => {
|
||||
const o = occupancy.get(d);
|
||||
const stacks = categoricalSelection[colorAccessor].categoryValues.map(d => {
|
||||
const o = occupancy.get(d);
|
||||
|
||||
const scaledValue = x(o);
|
||||
const scaledValue = x(o);
|
||||
|
||||
const stackItem = {
|
||||
key: d,
|
||||
value: o || 0,
|
||||
rectWidth: o ? scaledValue : 0,
|
||||
offset: currentOffset,
|
||||
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)"
|
||||
};
|
||||
currentOffset += o ? scaledValue : 0;
|
||||
return stackItem;
|
||||
}
|
||||
);
|
||||
const stackItem = {
|
||||
key: d,
|
||||
value: o || 0,
|
||||
rectWidth: o ? scaledValue : 0,
|
||||
offset: currentOffset,
|
||||
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)"
|
||||
};
|
||||
currentOffset += o ? scaledValue : 0;
|
||||
return stackItem;
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -7,11 +7,11 @@ import { countCategoryValues2D } from "../../util/stateManager/worldUtil";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState,
|
||||
colorScale: state.controls.colorScale,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
schema: _.get(state.controls.world, "schema", null),
|
||||
world: state.controls.world
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
schema: _.get(state.world, "schema", null),
|
||||
world: state.world
|
||||
}))
|
||||
class CategoryValue extends React.Component {
|
||||
toggleOff() {
|
||||
@@ -34,7 +34,7 @@ class CategoryValue extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
categoricalSelectionState,
|
||||
categoricalSelection,
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
colorAccessor,
|
||||
@@ -44,9 +44,9 @@ class CategoryValue extends React.Component {
|
||||
world
|
||||
} = this.props;
|
||||
|
||||
if (!categoricalSelectionState) return null;
|
||||
if (!categoricalSelection) return null;
|
||||
|
||||
const category = categoricalSelectionState[metadataField];
|
||||
const category = categoricalSelection[metadataField];
|
||||
const selected = category.categorySelected[categoryIndex];
|
||||
const count = category.categoryCounts[categoryIndex];
|
||||
const value = category.categoryValues[categoryIndex];
|
||||
@@ -65,11 +65,7 @@ class CategoryValue extends React.Component {
|
||||
})[0].categories;
|
||||
}
|
||||
|
||||
if (
|
||||
colorAccessor &&
|
||||
!isColorBy &&
|
||||
categoricalSelectionState[colorAccessor]
|
||||
) {
|
||||
if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) {
|
||||
occupancy = countCategoryValues2D(
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
@@ -85,6 +81,7 @@ class CategoryValue extends React.Component {
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
data-testclass="categorical-row"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -101,16 +98,23 @@ class CategoryValue extends React.Component {
|
||||
onChange={
|
||||
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
|
||||
}
|
||||
data-testclass="categorical-value-select"
|
||||
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
|
||||
checked={selected}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="bp3-control-indicator" />
|
||||
{displayString}
|
||||
<span
|
||||
data-testid={`categorical-value-${metadataField}-${displayString}`}
|
||||
data-testclass="categorical-value"
|
||||
>
|
||||
{displayString}
|
||||
</span>
|
||||
</label>
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{colorAccessor &&
|
||||
!isColorBy &&
|
||||
categoricalSelectionState[colorAccessor] ? (
|
||||
categoricalSelection[colorAccessor] ? (
|
||||
<Occupancy
|
||||
occupancy={occupancy.get(
|
||||
category.categoryValues[categoryIndex]
|
||||
@@ -121,7 +125,12 @@ class CategoryValue extends React.Component {
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<span>{count}</span>
|
||||
<span
|
||||
data-testclass="categorical-value-count"
|
||||
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<svg
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
|
||||
@@ -9,11 +9,10 @@ import * as globals from "../../globals";
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
|
||||
@connect(state => ({
|
||||
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null),
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null),
|
||||
schema: _.get(state.controls.world, "schema", null)
|
||||
obsAnnotations: _.get(state.world, "obsAnnotations", null),
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
schema: _.get(state.world, "schema", null)
|
||||
}))
|
||||
class Continuous extends React.Component {
|
||||
constructor(props) {
|
||||
|
||||
@@ -98,8 +98,8 @@ const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||
};
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
responsive: state.responsive
|
||||
}))
|
||||
class ContinuousLegend extends React.Component {
|
||||
|
||||
@@ -32,6 +32,9 @@ class CellSetButton extends React.Component {
|
||||
render() {
|
||||
const { differential, eitherCellSetOneOrTwo } = this.props;
|
||||
const cellListName = `celllist${eitherCellSetOneOrTwo}`;
|
||||
let cells_selected = differential[cellListName]
|
||||
? differential[cellListName].length
|
||||
: 0;
|
||||
return (
|
||||
<Tooltip
|
||||
content="Save current selection for differential expression computation"
|
||||
@@ -46,9 +49,10 @@ class CellSetButton extends React.Component {
|
||||
>
|
||||
{eitherCellSetOneOrTwo}
|
||||
{": "}
|
||||
{differential[cellListName]
|
||||
? `${differential[cellListName].length} cells`
|
||||
: "0 cells"}
|
||||
<span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}>
|
||||
{cells_selected}
|
||||
</span>
|
||||
{" cells"}
|
||||
</AnchorButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,8 @@ import CellSetButton from "./cellSetButtons";
|
||||
|
||||
@connect(state => ({
|
||||
differential: state.differential,
|
||||
world: state.controls.world,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter
|
||||
}))
|
||||
class Expression extends React.Component {
|
||||
constructor(props) {
|
||||
|
||||
@@ -35,6 +35,7 @@ const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
|
||||
<MenuItem
|
||||
active={modifiers.active}
|
||||
disabled={modifiers.disabled}
|
||||
data-testid={`suggest-menu-item-${geneName}`}
|
||||
// Use of annotations in this way is incorrect and dataset specific.
|
||||
// See https://github.com/chanzuckerberg/cellxgene/issues/483
|
||||
// label={gene.n_counts}
|
||||
@@ -57,11 +58,11 @@ const filterGenes = (query, genes) =>
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null),
|
||||
obsAnnotations: _.get(state.world, "obsAnnotations", null),
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||
world: state.controls.world,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
world: state.world,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
differential: state.differential
|
||||
};
|
||||
})
|
||||
@@ -117,11 +118,9 @@ class GeneExpression extends React.Component {
|
||||
} else if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
|
||||
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||
} else {
|
||||
dispatch({ type: "single user defined gene start" });
|
||||
dispatch(actions.requestUserDefinedGene(gene));
|
||||
dispatch({
|
||||
type: "user defined gene",
|
||||
data: gene
|
||||
});
|
||||
dispatch({ type: "single user defined gene complete" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +135,7 @@ class GeneExpression extends React.Component {
|
||||
if (bulkAdd !== "") {
|
||||
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||
|
||||
dispatch({ type: "bulk user defined gene start" });
|
||||
genes.forEach(gene => {
|
||||
if (gene.length === 0) {
|
||||
keepAroundErrorToast("Must enter a gene name.");
|
||||
@@ -149,12 +149,9 @@ class GeneExpression extends React.Component {
|
||||
);
|
||||
} else {
|
||||
dispatch(actions.requestUserDefinedGene(gene));
|
||||
dispatch({
|
||||
type: "user defined gene",
|
||||
data: gene
|
||||
});
|
||||
}
|
||||
});
|
||||
dispatch({ type: "bulk user defined gene complete" });
|
||||
}
|
||||
|
||||
this.setState({ bulkAdd: "" });
|
||||
@@ -195,6 +192,7 @@ class GeneExpression extends React.Component {
|
||||
style={{ marginRight: 5 }}
|
||||
minimal
|
||||
small
|
||||
data-testid="tab-autosuggest"
|
||||
onClick={() => {
|
||||
this.setState({ tab: "autosuggest" });
|
||||
}}
|
||||
@@ -205,6 +203,7 @@ class GeneExpression extends React.Component {
|
||||
active={tab === "bulkadd"}
|
||||
minimal
|
||||
small
|
||||
data-testid="section-bulk-add"
|
||||
onClick={() => {
|
||||
this.setState({ tab: "bulkadd" });
|
||||
}}
|
||||
@@ -272,6 +271,7 @@ class GeneExpression extends React.Component {
|
||||
this.setState({ bulkAdd: e.target.value });
|
||||
}}
|
||||
id="text-input-bulk-add"
|
||||
data-testid="input-bulk-add"
|
||||
placeholder={this.placeholderGeneNames()}
|
||||
value={bulkAdd}
|
||||
/>
|
||||
|
||||
@@ -5,25 +5,44 @@ import * as d3 from "d3";
|
||||
import { connect } from "react-redux";
|
||||
import mat4 from "gl-mat4";
|
||||
import _regl from "regl";
|
||||
import { Button, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import {
|
||||
Button,
|
||||
AnchorButton,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Position
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import setupSVGandBrushElements from "./setupSVGandBrush";
|
||||
import actions from "../../actions";
|
||||
import _camera from "../../util/camera";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import scaleLinear from "../../util/scaleLinear";
|
||||
import { World } from "../../util/stateManager";
|
||||
|
||||
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
|
||||
|
||||
@connect(state => ({
|
||||
world: state.controls.world,
|
||||
universe: state.controls.universe,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
world: state.world,
|
||||
universe: state.universe,
|
||||
crossfilter: state.crossfilter,
|
||||
responsive: state.responsive,
|
||||
colorRGB: _.get(state.controls, "colorRGB", null),
|
||||
colorRGB: state.colors.rgb,
|
||||
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null),
|
||||
resettingInterface: state.controls.resettingInterface
|
||||
resettingInterface: state.controls.resettingInterface,
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
diffexpGenes: state.controls.diffexpGenes,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
celllist1: state.differential.celllist1,
|
||||
celllist2: state.differential.celllist2,
|
||||
library_versions: _.get(state.config, "library_versions", null),
|
||||
undoDisabled: state["@@undoable/past"].length === 0,
|
||||
redoDisabled: state["@@undoable/future"].length === 0
|
||||
}))
|
||||
class Graph extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -85,13 +104,7 @@ class Graph extends React.Component {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { renderCache } = this;
|
||||
const {
|
||||
world,
|
||||
crossfilter,
|
||||
colorRGB,
|
||||
responsive,
|
||||
selectionUpdate
|
||||
} = this.props;
|
||||
const { world, crossfilter, colorRGB, responsive } = this.props;
|
||||
const {
|
||||
reglRender,
|
||||
mode,
|
||||
@@ -141,11 +154,7 @@ class Graph extends React.Component {
|
||||
}
|
||||
|
||||
// Colors for each point - a cached value that only changes when
|
||||
// the cell metadata changes (done by updateCellColors middleware).
|
||||
// NOTE: this is a slightly pessimistic assumption, as the metadata
|
||||
// could have changed for some other reason, but for now color is
|
||||
// the only metadata that changes client-side. If this is problematic,
|
||||
// we could add some sort of color-specific indicator to the app state.
|
||||
// the cell metadata changes.
|
||||
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) {
|
||||
const rgb = colorRGB;
|
||||
if (!renderCache.colors) {
|
||||
@@ -159,11 +168,11 @@ class Graph extends React.Component {
|
||||
|
||||
// Sizes for each point - updates are triggered only when selected
|
||||
// obs change
|
||||
if (!renderCache.sizes || selectionUpdate !== prevProps.selectionUpdate) {
|
||||
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) {
|
||||
if (!renderCache.sizes) {
|
||||
renderCache.sizes = new Float32Array(nObs);
|
||||
}
|
||||
crossfilter.fillByIsFiltered(renderCache.sizes, 4, 0.2);
|
||||
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
|
||||
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
|
||||
}
|
||||
|
||||
@@ -202,6 +211,56 @@ class Graph extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
isResetDisabled = () => {
|
||||
/*
|
||||
Reset should be disabled when all of the following are true:
|
||||
* nothing is selected in the crossfilter
|
||||
* world EQ universe
|
||||
* nothing is colored by
|
||||
* there are no userDefinedGenes or diffexpGenes displayed
|
||||
* scatterplot is not displayed
|
||||
* nothing in cellset1 or cellset2
|
||||
*/
|
||||
const {
|
||||
crossfilter,
|
||||
world,
|
||||
universe,
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
colorAccessor,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
celllist1,
|
||||
celllist2
|
||||
} = this.props;
|
||||
|
||||
if (!crossfilter || !world || !universe) {
|
||||
return false;
|
||||
}
|
||||
const nothingSelected = crossfilter.countSelected() === crossfilter.size();
|
||||
const nothingColoredBy = !colorAccessor;
|
||||
const noGenes = userDefinedGenes.length === 0 && diffexpGenes.length === 0;
|
||||
const scatterNotDpl = !scatterplotXXaccessor || !scatterplotYYaccessor;
|
||||
const nothingInCellsets = !celllist1 && !celllist2;
|
||||
|
||||
return (
|
||||
nothingSelected &&
|
||||
World.worldEqUniverse(world, universe) &&
|
||||
nothingColoredBy &&
|
||||
noGenes &&
|
||||
scatterNotDpl &&
|
||||
nothingInCellsets
|
||||
);
|
||||
};
|
||||
|
||||
resetInterface = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "interface reset started"
|
||||
});
|
||||
dispatch(actions.resetInterface());
|
||||
};
|
||||
|
||||
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
@@ -335,12 +394,21 @@ class Graph extends React.Component {
|
||||
|
||||
// when a lasso is completed, filter to the points within the lasso polygon
|
||||
handleLassoEnd(polygon) {
|
||||
const minimumPolygoneArea = 10;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "lasso selection",
|
||||
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
|
||||
});
|
||||
if (
|
||||
polygon.length < 3 ||
|
||||
Math.abs(d3.polygonArea(polygon)) < minimumPolygoneArea
|
||||
) {
|
||||
// if less than three points, or super small area, treat as a clear selection.
|
||||
dispatch({ type: "lasso deselect" });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "lasso selection",
|
||||
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOpacityRangeChange(e) {
|
||||
@@ -351,22 +419,16 @@ class Graph extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
resetInterface() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "interface reset started"
|
||||
});
|
||||
dispatch(actions.resetInterface());
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatch,
|
||||
responsive,
|
||||
crossfilter,
|
||||
resettingInterface
|
||||
resettingInterface,
|
||||
library_versions,
|
||||
undoDisabled,
|
||||
redoDisabled
|
||||
} = this.props;
|
||||
|
||||
const { mode } = this.state;
|
||||
return (
|
||||
<div id="graphWrapper">
|
||||
@@ -391,10 +453,11 @@ class Graph extends React.Component {
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
data-testid="subset-button"
|
||||
disabled={
|
||||
crossfilter &&
|
||||
(crossfilter.countFiltered() === 0 ||
|
||||
crossfilter.countFiltered() === crossfilter.size())
|
||||
(crossfilter.countSelected() === 0 ||
|
||||
crossfilter.countSelected() === crossfilter.size())
|
||||
}
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={() => {
|
||||
@@ -410,15 +473,14 @@ class Graph extends React.Component {
|
||||
position="left"
|
||||
>
|
||||
<AnchorButton
|
||||
disabled={
|
||||
false
|
||||
/* world && universe ? worldEqUniverse(world, universe) : false */
|
||||
}
|
||||
disabled={this.isResetDisabled()}
|
||||
type="button"
|
||||
loading={resettingInterface}
|
||||
intent="warning"
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={this.resetInterface.bind(this)}
|
||||
onClick={this.resetInterface}
|
||||
data-testid="reset"
|
||||
data-testclass={`resetting-${resettingInterface}`}
|
||||
>
|
||||
reset
|
||||
</AnchorButton>
|
||||
@@ -428,6 +490,7 @@ class Graph extends React.Component {
|
||||
<Tooltip content="Lasso selection" position="left">
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="mode-lasso"
|
||||
className="bp3-button bp3-icon-polygon-filter"
|
||||
active={mode === "lasso"}
|
||||
onClick={() => {
|
||||
@@ -443,6 +506,7 @@ class Graph extends React.Component {
|
||||
<Tooltip content="Pan and zoom" position="left">
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="mode-pan-zoom"
|
||||
className="bp3-button bp3-icon-zoom-in"
|
||||
active={mode === "zoom"}
|
||||
onClick={() => {
|
||||
@@ -455,8 +519,84 @@ class Graph extends React.Component {
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Undo" position="left">
|
||||
<AnchorButton
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-undo"
|
||||
disabled={undoDisabled}
|
||||
onClick={() => {
|
||||
dispatch({ type: "@@undoable/undo" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Redo" position="left">
|
||||
<AnchorButton
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-redo"
|
||||
disabled={redoDisabled}
|
||||
onClick={() => {
|
||||
dispatch({ type: "@@undoable/redo" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 10 }}>
|
||||
<Popover
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
href="https://chanzuckerberg.github.io/cellxgene/faq.html"
|
||||
target="_blank"
|
||||
icon="help"
|
||||
text="FAQ"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://join-cziscience-slack.herokuapp.com/"
|
||||
target="_blank"
|
||||
icon="chat"
|
||||
text="Chat"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://chanzuckerberg.github.io/cellxgene/"
|
||||
target="_blank"
|
||||
icon="book"
|
||||
text="Docs"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://github.com/chanzuckerberg/cellxgene"
|
||||
target="_blank"
|
||||
icon="git-branch"
|
||||
text="Github"
|
||||
/>
|
||||
<MenuItem
|
||||
target="_blank"
|
||||
text={`cellxgene v${
|
||||
library_versions && library_versions.cellxgene
|
||||
? library_versions.cellxgene
|
||||
: null
|
||||
}`}
|
||||
/>
|
||||
<MenuItem text="MIT License" />
|
||||
</Menu>
|
||||
}
|
||||
position={Position.BOTTOM_RIGHT}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-cog"
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -477,7 +617,7 @@ class Graph extends React.Component {
|
||||
<canvas
|
||||
width={responsive.width - this.graphPaddingRight}
|
||||
height={responsive.height - this.graphPaddingTop}
|
||||
data-testid="layout"
|
||||
data-testid="layout-graph"
|
||||
ref={canvas => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
|
||||
@@ -22,12 +22,8 @@ import { margin, width, height } from "./util";
|
||||
import finiteExtent from "../../util/finiteExtent";
|
||||
|
||||
@connect(state => {
|
||||
const {
|
||||
world,
|
||||
crossfilter,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor
|
||||
} = state.controls;
|
||||
const { world, crossfilter } = state;
|
||||
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
|
||||
const expressionX =
|
||||
world &&
|
||||
scatterplotXXaccessor &&
|
||||
@@ -44,9 +40,9 @@ import finiteExtent from "../../util/finiteExtent";
|
||||
return {
|
||||
world,
|
||||
|
||||
colorRGB: state.controls.colorRGB,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
colorRGB: state.colors.rgb,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
|
||||
// Accessors are var/gene names (strings)
|
||||
scatterplotXXaccessor,
|
||||
@@ -58,9 +54,7 @@ import finiteExtent from "../../util/finiteExtent";
|
||||
expressionX,
|
||||
expressionY,
|
||||
|
||||
crossfilter,
|
||||
// updated whenever the crossfilter selection is updated
|
||||
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
|
||||
crossfilter
|
||||
};
|
||||
})
|
||||
class Scatterplot extends React.Component {
|
||||
@@ -136,8 +130,7 @@ class Scatterplot extends React.Component {
|
||||
scatterplotYYaccessor,
|
||||
expressionX,
|
||||
expressionY,
|
||||
colorRGB,
|
||||
selectionUpdate
|
||||
colorRGB
|
||||
} = this.props;
|
||||
const {
|
||||
reglRender,
|
||||
@@ -210,11 +203,11 @@ class Scatterplot extends React.Component {
|
||||
|
||||
// Sizes for each point - updates are triggered only when selected
|
||||
// obs change
|
||||
if (!renderCache.sizes || selectionUpdate !== prevProps.selctionUpdate) {
|
||||
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) {
|
||||
if (!renderCache.sizes) {
|
||||
renderCache.sizes = new Float32Array(cellCount);
|
||||
}
|
||||
crossfilter.fillByIsFiltered(renderCache.sizes, 4, 0.2);
|
||||
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
|
||||
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
|
||||
}
|
||||
|
||||
@@ -345,14 +338,12 @@ class Scatterplot extends React.Component {
|
||||
<Button
|
||||
type="button"
|
||||
minimal
|
||||
onClick={() => {
|
||||
data-testid="clear-scatterplot"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "clear scatterplot"
|
||||
});
|
||||
dispatch({
|
||||
type: "reset colorscale"
|
||||
});
|
||||
}}
|
||||
})
|
||||
}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
@@ -368,6 +359,7 @@ class Scatterplot extends React.Component {
|
||||
<canvas
|
||||
width={width}
|
||||
height={height}
|
||||
data-testid="scatterplot"
|
||||
style={{
|
||||
marginLeft: margin.left - 7,
|
||||
marginTop: margin.top
|
||||
|
||||
@@ -14,6 +14,7 @@ const setupScatterplot = (width, height, margin) => {
|
||||
.append("svg")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
.attr("data-testid", "scatterplot-svg")
|
||||
.append("g")
|
||||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
import { Colors } from "@blueprintjs/core";
|
||||
|
||||
// jshint esversion: 6
|
||||
/* these will be either (preferably) specified or inferred */
|
||||
export const categories = [
|
||||
"Sample.type",
|
||||
"Selection",
|
||||
"Location",
|
||||
"Sample.name",
|
||||
"Class",
|
||||
"Neoplastic"
|
||||
];
|
||||
export const continuous = [
|
||||
"Total_reads",
|
||||
"Unique_reads",
|
||||
"Unique_reads_percent",
|
||||
"ERCC_reads",
|
||||
"Non_ERCC_reads",
|
||||
"ERCC_to_non_ERCC",
|
||||
"Genes_detected",
|
||||
"Multimapping_reads_percent",
|
||||
"Splice_sites_AT.AC",
|
||||
"Splice_sites_Annotated",
|
||||
"Splice_sites_GC.AG",
|
||||
"Splice_sites_GT.AG",
|
||||
"Splice_sites_non_canonical",
|
||||
"Splice_sites_total",
|
||||
"Unmapped_mismatch",
|
||||
"Unmapped_other",
|
||||
"Unmapped_short"
|
||||
];
|
||||
|
||||
/* if a categorical metadata field has more options than this, truncate */
|
||||
export const maxCategoricalOptionsToDisplay = 100;
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic";
|
||||
import * as globals from "../globals";
|
||||
import parseRGB from "../util/parseRGB";
|
||||
import finiteExtent from "../util/finiteExtent";
|
||||
|
||||
/*
|
||||
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
|
||||
storeInstance =>
|
||||
functionToCallWithAnActionThatWillSendItToTheNextMiddleware =>
|
||||
actionThatDispatchWasCalledWith =>
|
||||
valueToUseAsTheReturnValueOfTheDispatchCall
|
||||
*/
|
||||
|
||||
/*
|
||||
What this file does:
|
||||
|
||||
1. fire a filter action anywhere in the app
|
||||
2. ** this middleware checks to see the state of all the currently selected filters,
|
||||
including the new one
|
||||
3. ** create updated selection from a copy of all the cells presently on the client
|
||||
(this may be a subset of 'all')
|
||||
4. ** append that new selection to the action so that it magically appears in the reducer
|
||||
just because the action was fired
|
||||
|
||||
This is nice because we keep a lot of filtering business logic centralized
|
||||
(what it means in practice to be selected)
|
||||
*/
|
||||
|
||||
const updateCellColorsMiddleware = store => next => action => {
|
||||
const s = store.getState();
|
||||
|
||||
/*
|
||||
this is a hardcoded map of the things we need to keep an eye on and update
|
||||
global cell selection in response to
|
||||
*/
|
||||
const filterJustChanged =
|
||||
action.type === "color by expression" ||
|
||||
action.type === "color by continuous metadata" ||
|
||||
action.type === "color by categorical metadata";
|
||||
|
||||
const obsAnnotations = _.get(s.controls, "world.obsAnnotations", null);
|
||||
if (!filterJustChanged || !obsAnnotations) {
|
||||
return next(
|
||||
action
|
||||
); /* if the cells haven't loaded or the action wasn't a color change, bail */
|
||||
}
|
||||
|
||||
let colorScale;
|
||||
const colorsByRGB = new Array(obsAnnotations.length);
|
||||
|
||||
/*
|
||||
in plain language...
|
||||
(a) once the cells have loaded.
|
||||
(b) each time a user changes a color control we need to update cellsMetadata colors
|
||||
This is available to all the draw functions as controls.colorRGB[index]
|
||||
*/
|
||||
|
||||
if (action.type === "color by categorical metadata") {
|
||||
const { categories } = _.filter(s.controls.world.schema.annotations.obs, {
|
||||
name: action.colorAccessor
|
||||
})[0];
|
||||
|
||||
colorScale = d3
|
||||
.scaleSequential(interpolateRainbow)
|
||||
.domain([0, categories.length]);
|
||||
|
||||
/* pre-create colors - much faster than doing it for each obs */
|
||||
const colors = _.transform(categories, (acc, cat, idx) => {
|
||||
acc[cat] = parseRGB(colorScale(idx));
|
||||
});
|
||||
|
||||
const key = action.colorAccessor;
|
||||
const col = obsAnnotations.col(key).asArray();
|
||||
for (let i = 0, len = obsAnnotations.length; i < len; i += 1) {
|
||||
const cat = col[i];
|
||||
colorsByRGB[i] = colors[cat];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "color by continuous metadata") {
|
||||
const colorBins = 100;
|
||||
const { min, max } = action.rangeForColorAccessor;
|
||||
colorScale = d3
|
||||
.scaleQuantile()
|
||||
.domain([min, max])
|
||||
.range(_.range(colorBins - 1, -1, -1));
|
||||
|
||||
/* pre-create colors - much faster than doing it for each obs */
|
||||
const colors = new Array(colorBins);
|
||||
for (let i = 0; i < colorBins; i += 1) {
|
||||
colors[i] = parseRGB(interpolateCool(i / colorBins));
|
||||
}
|
||||
|
||||
const key = action.colorAccessor;
|
||||
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
|
||||
const col = obsAnnotations.col(key).asArray();
|
||||
for (let i = 0, len = obsAnnotations.length; i < len; i += 1) {
|
||||
const val = col[i];
|
||||
if (Number.isFinite(val)) {
|
||||
const c = colorScale(val);
|
||||
colorsByRGB[i] = colors[c];
|
||||
} else {
|
||||
colorsByRGB[i] = nonFiniteColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "color by expression") {
|
||||
const { gene, data } = action;
|
||||
const expression = data[gene]; // Float32Array
|
||||
const colorBins = 100;
|
||||
const [min, max] = finiteExtent(expression);
|
||||
colorScale = d3
|
||||
.scaleQuantile()
|
||||
.domain([min, max])
|
||||
.range(_.range(colorBins - 1, -1, -1));
|
||||
|
||||
/* pre-create colors - much faster than doing it for each obs */
|
||||
const colors = new Array(colorBins);
|
||||
for (let i = 0; i < colorBins; i += 1) {
|
||||
colors[i] = parseRGB(interpolateCool(i / colorBins));
|
||||
}
|
||||
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
|
||||
|
||||
for (let i = 0, len = expression.length; i < len; i += 1) {
|
||||
const e = expression[i];
|
||||
if (Number.isFinite(e)) {
|
||||
const c = colorScale(e);
|
||||
colorsByRGB[i] = colors[c];
|
||||
} else {
|
||||
colorsByRGB[i] = nonFiniteColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
append the result of all the filters to the action the user just triggered
|
||||
*/
|
||||
const modifiedAction = Object.assign({}, action, {
|
||||
colors: { rgb: colorsByRGB },
|
||||
colorScale
|
||||
});
|
||||
|
||||
return next(modifiedAction);
|
||||
};
|
||||
|
||||
export default updateCellColorsMiddleware;
|
||||
@@ -1,87 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
// import uri from "urijs";
|
||||
|
||||
/*
|
||||
NOTE: file currently not used, but retained as we expect to reinstate features in this
|
||||
area shortly.
|
||||
*/
|
||||
|
||||
/*
|
||||
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
|
||||
storeInstance
|
||||
=> functionToCallWithAnActionThatWillSendItToTheNextMiddleware
|
||||
=> actionThatDispatchWasCalledWith
|
||||
=> valueToUseAsTheReturnValueOfTheDispatchCall
|
||||
*/
|
||||
|
||||
const updateURLMiddleware = (/* store */) => next => action => {
|
||||
// const oldState = store.getState();
|
||||
const nextAction = next(action);
|
||||
|
||||
if (action.type === "url changed") {
|
||||
/* we don't handle pop state here - we handle it in the url reducer */
|
||||
return nextAction;
|
||||
}
|
||||
|
||||
// const state = store.getState();
|
||||
|
||||
/************************************************************************
|
||||
*************************************************************************
|
||||
1. Redux app state just changed. Clear URL, and then update it.
|
||||
1a. We get the whole state tree to construct the url!
|
||||
1b. But (see reducers/url.js) we try to centralize it because...
|
||||
1c. ...the back button / initial load case ('url changed' return above)
|
||||
means that we have to listen for 'url changed' and construct state
|
||||
from the browser
|
||||
*************************************************************************
|
||||
************************************************************************/
|
||||
|
||||
// const oldURI = URI(window.location.href)
|
||||
// const newURI = URI(oldURI).setQuery({})
|
||||
|
||||
// if (window.location.search === "") {
|
||||
// newURL = uri.addQuery(category, value).toString(); /* #1 */
|
||||
// } else if (uri.hasQuery(category, value) || uri.hasQuery(category, value, true)) { /* true param here means check arrays as well http://medialize.github.io/URI.js/docs.html#search-has */
|
||||
// newURL = uri.removeQuery(category, value).toString(); /* #4 */
|
||||
// } else {
|
||||
// newURL = uri.addQuery(category, value).toString(); /* #2 & #3 are handled by URI */
|
||||
// }
|
||||
//
|
||||
// window.history.pushState("", "", newURL)
|
||||
|
||||
//
|
||||
// // Internal helper for working with URIs
|
||||
// const oldURI = new URI(window.location.href);
|
||||
// const newURI = new URI(oldURI).setQueryData({});
|
||||
//
|
||||
// newURI.setPath('/foo/bar');
|
||||
//
|
||||
// // Set the path based on state
|
||||
// if (!state.isOnLandingPage && state.project.id) {
|
||||
// newURI.setPath(newURI.getPath() + state.project.id + '/');
|
||||
// newURI.addQueryData('baz', state.mode);
|
||||
// newURI.addQueryData('bat', state.selection.activePageID);
|
||||
// } else {
|
||||
// newURI.setPath(newURI.getPath() + state.landingSection + '/');
|
||||
// }
|
||||
//
|
||||
// // Avoid URL thrashing by replacing state while loading instead of pushing
|
||||
// const newPath = newURI.toString();
|
||||
// const oldPath = oldURI.toString();
|
||||
// if (newPath !== oldPath) {
|
||||
// if (
|
||||
// (oldState.mode === 'asdf' &&
|
||||
// state.mode === 'asdf' &&
|
||||
// !oldState.isOnLandingPage) ||
|
||||
// oldState.isLoadingProject !== state.isLoadingProject
|
||||
// ) {
|
||||
// window.history.replaceState(null, null, newPath);
|
||||
// } else {
|
||||
// window.history.pushState(null, null, newPath);
|
||||
// }
|
||||
// }
|
||||
|
||||
return nextAction;
|
||||
};
|
||||
|
||||
export default updateURLMiddleware;
|
||||
@@ -0,0 +1,44 @@
|
||||
export default function cascadeReducers(arg) {
|
||||
/*
|
||||
Combined a set of cascading reducers into a single reducer. Cascading
|
||||
reducers are reducers which may rely on state computed by another reducer.
|
||||
Therefore, they:
|
||||
- must be composed in a particular order (currently, this is a simple
|
||||
linear list of reducers, run in list order)
|
||||
- must have access to partially updated "next state" so they can further
|
||||
derive state.
|
||||
|
||||
Parameter is one of:
|
||||
- a Map object
|
||||
- an array of tuples, [ [key1, reducer1], [key2, reducer2], ... ]
|
||||
Ie, cascadeReducers([ ["a", reduceA], ["b", reduceB] ])
|
||||
|
||||
Each reducer will be called with the sigature:
|
||||
(prevState, action, sharedNextState, sharedPrevState) => newState
|
||||
|
||||
cascadeReducers will build a composite newState object, much
|
||||
like combinedReducers. Additional semantics:
|
||||
- reducers guaranteed to be called in order
|
||||
- each reducer will receive shared objects
|
||||
*/
|
||||
const reducers = arg instanceof Map ? arg : new Map(arg);
|
||||
const reducerKeys = [...reducers.keys()];
|
||||
return (prevState, action) => {
|
||||
const nextState = {};
|
||||
let stateChange = false;
|
||||
for (let i = 0, l = reducerKeys.length; i < l; i += 1) {
|
||||
const key = reducerKeys[i];
|
||||
const reducer = reducers.get(key);
|
||||
const prevStateForKey = prevState ? prevState[key] : undefined;
|
||||
const nextStateForKey = reducer(
|
||||
prevStateForKey,
|
||||
action,
|
||||
nextState,
|
||||
prevState
|
||||
);
|
||||
nextState[key] = nextStateForKey;
|
||||
stateChange = stateChange || nextStateForKey !== prevStateForKey;
|
||||
}
|
||||
return stateChange ? nextState : prevState;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import { ControlsHelpers } from "../util/stateManager";
|
||||
import * as globals from "../globals";
|
||||
|
||||
function maxCategoryItems(state) {
|
||||
return _.get(
|
||||
state.config,
|
||||
"parameters.max-category-items",
|
||||
globals.configDefaults.parameters["max-category-items"]
|
||||
);
|
||||
}
|
||||
|
||||
const CategoricalSelection = (
|
||||
state,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "set World to current selection":
|
||||
case "reset World to eq Universe": {
|
||||
const { world } = nextSharedState;
|
||||
return ControlsHelpers.createCategoricalSelection(
|
||||
maxCategoryItems(prevSharedState),
|
||||
world
|
||||
);
|
||||
}
|
||||
|
||||
case "categorical metadata filter select": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const newCategorySelected = Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = true;
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter deselect": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const newCategorySelected = Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = false;
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter none of these": {
|
||||
/*
|
||||
set all categories in this field to false.
|
||||
*/
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
).fill(false)
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter all of these": {
|
||||
/*
|
||||
set all categories in this field to true.
|
||||
*/
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
).fill(true)
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default CategoricalSelection;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createColors } from "../util/stateManager";
|
||||
|
||||
const ColorsReducer = (
|
||||
state = {
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
rgb: null,
|
||||
scale: null
|
||||
},
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "reset World to eq Universe": {
|
||||
const { world } = nextSharedState;
|
||||
const colorMode = null;
|
||||
const colorAccessor = null;
|
||||
const { rgb, scale } = createColors(world, colorMode);
|
||||
return {
|
||||
...state,
|
||||
colorAccessor,
|
||||
colorMode,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "set World to current selection": {
|
||||
const { colorMode, colorAccessor } = state;
|
||||
const { world } = nextSharedState;
|
||||
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
|
||||
return {
|
||||
...state,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "reset colorscale": {
|
||||
const { world } = prevSharedState;
|
||||
const { rgb, scale } = createColors(world);
|
||||
return {
|
||||
...state,
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
const { world } = prevSharedState;
|
||||
const { rgb, scale } = createColors(
|
||||
world,
|
||||
action.type,
|
||||
action.colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
colorMode: action.type,
|
||||
colorAccessor: action.colorAccessor,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "color by expression": {
|
||||
const { world } = prevSharedState;
|
||||
const { rgb, scale } = createColors(world, action.type, action.gene);
|
||||
return {
|
||||
...state,
|
||||
colorMode: action.type,
|
||||
colorAccessor: action.gene,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ColorsReducer;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { makeContinuousDimensionName } from "../util/nameCreators";
|
||||
|
||||
const ContinuousSelection = (state = {}, action) => {
|
||||
switch (action.type) {
|
||||
case "reset World to eq Universe": {
|
||||
return {};
|
||||
}
|
||||
case "continuous metadata histogram start":
|
||||
case "continuous metadata histogram brush":
|
||||
case "continuous metadata histogram end": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
[name]: action.range
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ContinuousSelection;
|
||||
Vendored
+11
-423
@@ -2,17 +2,7 @@
|
||||
|
||||
import _ from "lodash";
|
||||
|
||||
import { World, WorldUtil, ControlsHelper } from "../util/stateManager";
|
||||
import parseRGB from "../util/parseRGB";
|
||||
import Crossfilter from "../util/typedCrossfilter";
|
||||
import * as globals from "../globals";
|
||||
import {
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName,
|
||||
makeContinuousDimensionName
|
||||
} from "../util/nameCreators";
|
||||
import { WorldUtil } from "../util/stateManager";
|
||||
|
||||
const Controls = (
|
||||
state = {
|
||||
@@ -20,38 +10,23 @@ const Controls = (
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// configuration
|
||||
maxCategoryItems: globals.configDefaults.parameters["max-category-items"],
|
||||
|
||||
// the whole big bang
|
||||
universe: null,
|
||||
fullUniverseCache: null,
|
||||
|
||||
// all of the data + selection state
|
||||
world: null,
|
||||
colorRGB: null,
|
||||
categoricalSelectionState: null,
|
||||
crossfilter: null,
|
||||
dimensionMap: null,
|
||||
userDefinedGenes: [],
|
||||
userDefinedGenesLoading: false,
|
||||
diffexpGenes: [],
|
||||
|
||||
colorAccessor: null,
|
||||
colorScale: null,
|
||||
resettingInterface: false,
|
||||
|
||||
opacityForDeselectedCells: 0.2,
|
||||
graphBrushSelection: null,
|
||||
continuousSelection: null,
|
||||
scatterplotXXaccessor: null, // just easier to read
|
||||
scatterplotYYaccessor: null,
|
||||
axesHaveBeenDrawn: false,
|
||||
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
|
||||
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
|
||||
__storedStateForCelllist2__: null
|
||||
},
|
||||
action
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
/*
|
||||
For now, log anything looking like an error to the console.
|
||||
@@ -65,199 +40,32 @@ const Controls = (
|
||||
Initialization, World/Universe management
|
||||
and data loading.
|
||||
******************************************************/
|
||||
case "configuration load complete": {
|
||||
// there are a couple of configuration items we need to retain
|
||||
return {
|
||||
...state,
|
||||
maxCategoryItems: _.get(
|
||||
state.config,
|
||||
"parameters.max-category-items",
|
||||
globals.configDefaults.parameters["max-category-items"]
|
||||
)
|
||||
};
|
||||
}
|
||||
case "initial data load start": {
|
||||
return { ...state, loading: true };
|
||||
}
|
||||
case "initial data load complete (universe exists)": {
|
||||
/* first light - create world & other data-driven defaults */
|
||||
const { universe } = action;
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
const colorRGB = new Array(universe.nObs).fill(
|
||||
parseRGB(globals.defaultCellColor)
|
||||
);
|
||||
const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
|
||||
state,
|
||||
world
|
||||
);
|
||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||
WorldUtil.clearCaches();
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: null,
|
||||
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,
|
||||
colorRGB,
|
||||
categoricalSelectionState,
|
||||
crossfilter,
|
||||
dimensionMap,
|
||||
colorAccessor: null,
|
||||
resettingInterface: false
|
||||
};
|
||||
}
|
||||
case "set World to current selection": {
|
||||
const { userDefinedGenes, diffexpGenes } = state;
|
||||
|
||||
/* Set viewable world to be the currently selected data */
|
||||
const world = World.createWorldFromCurrentSelection(
|
||||
action.universe,
|
||||
action.world,
|
||||
action.crossfilter
|
||||
);
|
||||
const colorRGB = new Array(world.nObs).fill(
|
||||
parseRGB(globals.defaultCellColor)
|
||||
);
|
||||
const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
|
||||
state,
|
||||
world
|
||||
);
|
||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||
const dimensionMap = {
|
||||
...World.createObsDimensionMap(crossfilter, world),
|
||||
...ControlsHelper.createGenesDimMap(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
crossfilter
|
||||
)
|
||||
};
|
||||
WorldUtil.clearCaches();
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: null,
|
||||
world,
|
||||
colorRGB,
|
||||
categoricalSelectionState,
|
||||
crossfilter,
|
||||
dimensionMap,
|
||||
colorAccessor: null
|
||||
};
|
||||
}
|
||||
case "expression load success": {
|
||||
const { world, universe } = state;
|
||||
let universeVarData = universe.varData;
|
||||
let worldVarData = world.varData;
|
||||
|
||||
// Load new expression data into the varData dataframes, if
|
||||
// not already present.
|
||||
_.forEach(action.expressionData, (val, key) => {
|
||||
// If not already in universe.varData, save entire expression column
|
||||
if (!universeVarData.hasCol(key)) {
|
||||
universeVarData = universeVarData.withCol(key, val);
|
||||
}
|
||||
|
||||
// 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,
|
||||
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 {
|
||||
...state,
|
||||
universe: {
|
||||
...universe,
|
||||
varData: universeVarData
|
||||
},
|
||||
world: {
|
||||
...world,
|
||||
varData: worldVarData
|
||||
}
|
||||
error: null
|
||||
};
|
||||
}
|
||||
case "request user defined gene started": {
|
||||
@@ -273,114 +81,50 @@ const Controls = (
|
||||
};
|
||||
}
|
||||
case "request user defined gene success": {
|
||||
const { world, crossfilter, dimensionMap, userDefinedGenes } = state;
|
||||
const _userDefinedGenes = userDefinedGenes.slice();
|
||||
const gene = action.data.genes[0];
|
||||
|
||||
dimensionMap[
|
||||
userDefinedDimensionName(gene)
|
||||
] = World.createVarDataDimension(world, crossfilter, gene);
|
||||
|
||||
const { userDefinedGenes } = state;
|
||||
const _userDefinedGenes = _.uniq(
|
||||
userDefinedGenes.concat(action.data.genes)
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
dimensionMap,
|
||||
userDefinedGenes: _userDefinedGenes,
|
||||
userDefinedGenesLoading: false
|
||||
};
|
||||
}
|
||||
case "request differential expression success": {
|
||||
const { world, crossfilter, dimensionMap } = state;
|
||||
const { world } = prevSharedState;
|
||||
const _diffexpGenes = [];
|
||||
|
||||
action.data.forEach(d => {
|
||||
_diffexpGenes.push(world.varAnnotations.at(d[0], "name"));
|
||||
});
|
||||
|
||||
_.forEach(_diffexpGenes, gene => {
|
||||
dimensionMap[diffexpDimensionName(gene)] = World.createVarDataDimension(
|
||||
world,
|
||||
crossfilter,
|
||||
gene
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
dimensionMap,
|
||||
diffexpGenes: _diffexpGenes
|
||||
};
|
||||
}
|
||||
case "clear differential expression": {
|
||||
const { world, dimensionMap } = state;
|
||||
const _dimensionMap = dimensionMap;
|
||||
_.forEach(action.diffExp, values => {
|
||||
const name = world.varAnnotations.at(values[0], "name");
|
||||
// clean up crossfilter dimensions
|
||||
const dimension = dimensionMap[diffexpDimensionName(name)];
|
||||
dimension.dispose();
|
||||
delete dimensionMap[diffexpDimensionName(name)];
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
dimensionMap: _dimensionMap,
|
||||
diffexpGenes: []
|
||||
};
|
||||
}
|
||||
case "user defined gene": {
|
||||
/*
|
||||
this could also live in expression success with a conditional,
|
||||
but that handles diffexp also
|
||||
*/
|
||||
const newUserDefinedGenes = state.userDefinedGenes.slice();
|
||||
newUserDefinedGenes.push(action.data);
|
||||
return {
|
||||
...state,
|
||||
userDefinedGenes: newUserDefinedGenes
|
||||
};
|
||||
}
|
||||
case "clear user defined gene": {
|
||||
const { userDefinedGenes, dimensionMap } = state;
|
||||
const { userDefinedGenes } = state;
|
||||
const newUserDefinedGenes = _.filter(
|
||||
userDefinedGenes,
|
||||
d => d !== action.data
|
||||
);
|
||||
|
||||
const dimension = dimensionMap[userDefinedDimensionName(action.data)];
|
||||
dimension.dispose();
|
||||
delete dimensionMap[userDefinedDimensionName(action.data)];
|
||||
|
||||
return {
|
||||
...state,
|
||||
dimensionMap,
|
||||
userDefinedGenes: newUserDefinedGenes
|
||||
};
|
||||
}
|
||||
case "clear all user defined genes": {
|
||||
const { userDefinedGenes, dimensionMap } = state;
|
||||
|
||||
_.forEach(userDefinedGenes, gene => {
|
||||
const dimension = dimensionMap[userDefinedDimensionName(gene)];
|
||||
dimension.dispose();
|
||||
delete dimensionMap[userDefinedDimensionName(gene)];
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
dimensionMap,
|
||||
userDefinedGenes: []
|
||||
};
|
||||
}
|
||||
case "reset colorscale": {
|
||||
const { world } = state;
|
||||
const colorRGB = new Array(world.nObs).fill(
|
||||
parseRGB(globals.defaultCellColor)
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
colorRGB,
|
||||
colorAccessor: null
|
||||
};
|
||||
}
|
||||
case "expression load error":
|
||||
case "initial data load error": {
|
||||
return {
|
||||
@@ -393,52 +137,6 @@ const Controls = (
|
||||
/*******************************
|
||||
User Events
|
||||
*******************************/
|
||||
case "graph brush selection change": {
|
||||
state.dimensionMap[layoutDimensionName("XY")].filterWithinRect(
|
||||
action.brushCoords.northwest,
|
||||
action.brushCoords.southeast
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
graphBrushSelection: action.brushCoords
|
||||
};
|
||||
}
|
||||
case "lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
state.dimensionMap[layoutDimensionName("XY")].filterAll();
|
||||
return {
|
||||
...state,
|
||||
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": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
|
||||
// action.selection: metadata name being selected
|
||||
// action.range: filter range, or null if deselected
|
||||
if (!action.range) {
|
||||
state.dimensionMap[name].filterAll();
|
||||
} else {
|
||||
state.dimensionMap[name].filterRange(action.range);
|
||||
}
|
||||
return { ...state };
|
||||
}
|
||||
case "change opacity deselected cells in 2d graph background":
|
||||
return {
|
||||
...state,
|
||||
@@ -457,116 +155,6 @@ const Controls = (
|
||||
resettingInterface: true
|
||||
};
|
||||
}
|
||||
/*******************************
|
||||
Categorical metadata
|
||||
*******************************/
|
||||
case "categorical metadata filter select": {
|
||||
const newCategorySelected = Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = true;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
|
||||
// update the filter to match all selected options
|
||||
const cat = newCategoricalSelectionState[action.metadataField];
|
||||
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
|
||||
ControlsHelper.selectedValuesForCategory(cat)
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter deselect": {
|
||||
const newCategorySelected = Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = false;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
|
||||
// update the filter to match all selected options
|
||||
const cat = newCategoricalSelectionState[action.metadataField];
|
||||
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
|
||||
ControlsHelper.selectedValuesForCategory(cat)
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter none of these": {
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state.categoricalSelectionState[action.metadataField]
|
||||
.categorySelected
|
||||
).fill(false)
|
||||
}
|
||||
};
|
||||
state.dimensionMap[
|
||||
obsAnnoDimensionName(action.metadataField)
|
||||
].filterNone();
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter all of these": {
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state.categoricalSelectionState[action.metadataField]
|
||||
.categorySelected
|
||||
).fill(true)
|
||||
}
|
||||
};
|
||||
state.dimensionMap[
|
||||
obsAnnoDimensionName(action.metadataField)
|
||||
].filterAll();
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
|
||||
/*******************************
|
||||
Color Scale
|
||||
*******************************/
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
return {
|
||||
...state,
|
||||
colorRGB: action.colors.rgb,
|
||||
colorAccessor: action.colorAccessor,
|
||||
colorScale: action.colorScale
|
||||
};
|
||||
}
|
||||
case "color by expression": {
|
||||
return {
|
||||
...state,
|
||||
colorRGB: action.colors.rgb,
|
||||
colorAccessor: action.gene,
|
||||
colorScale: action.colorScale
|
||||
};
|
||||
}
|
||||
|
||||
/*******************************
|
||||
Scatterplot
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import Crossfilter from "../util/typedCrossfilter";
|
||||
import { World, ControlsHelpers } from "../util/stateManager";
|
||||
import {
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName,
|
||||
makeContinuousDimensionName
|
||||
} from "../util/nameCreators";
|
||||
|
||||
const CrossfilterReducer = (
|
||||
state = null,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { world } = nextSharedState;
|
||||
const crossfilter = World.createObsDimensions(
|
||||
new Crossfilter(world.obsAnnotations),
|
||||
world
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "reset World to eq Universe": {
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
||||
const { world } = nextSharedState;
|
||||
const crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
prevSharedState.resetCache.crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "set World to current selection": {
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
||||
const { world } = nextSharedState;
|
||||
let crossfilter = new Crossfilter(world.obsAnnotations);
|
||||
crossfilter = World.createObsDimensions(crossfilter, world);
|
||||
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "request user defined gene success": {
|
||||
const { world } = prevSharedState;
|
||||
const gene = action.data.genes[0];
|
||||
return state.addDimension(
|
||||
userDefinedDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
);
|
||||
}
|
||||
|
||||
case "request differential expression success": {
|
||||
const { world } = prevSharedState;
|
||||
const genes = _.map(action.data, d =>
|
||||
world.varAnnotations.at(d[0], "name")
|
||||
);
|
||||
const crossfilter = _.reduce(
|
||||
genes,
|
||||
(xfltr, gene) =>
|
||||
xfltr.addDimension(
|
||||
diffexpDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
),
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "clear differential expression": {
|
||||
const { world } = prevSharedState;
|
||||
const crossfilter = _.reduce(
|
||||
action.diffExp,
|
||||
(xfltr, values) => {
|
||||
const name = world.varAnnotations.at(values[0], "name");
|
||||
return xfltr.delDimension(diffexpDimensionName(name));
|
||||
},
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "clear user defined gene": {
|
||||
return state.delDimension(userDefinedDimensionName(action.data));
|
||||
}
|
||||
|
||||
case "clear all user defined genes": {
|
||||
const { userDefinedGenes } = prevSharedState.controls;
|
||||
const crossfilter = _.reduce(
|
||||
userDefinedGenes,
|
||||
(xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)),
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "graph brush selection change": {
|
||||
const name = layoutDimensionName("XY");
|
||||
const [x0, y0] = action.brushCoords.northwest;
|
||||
const [x1, y1] = action.brushCoords.southeast;
|
||||
return state.select(name, {
|
||||
mode: "within-rect",
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1
|
||||
});
|
||||
}
|
||||
|
||||
case "lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
const name = layoutDimensionName("XY");
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
|
||||
case "lasso selection": {
|
||||
const { polygon } = action;
|
||||
const name = layoutDimensionName("XY");
|
||||
if (polygon.length < 3) {
|
||||
// single point or a line is not a polygon, and is therefore a deselect
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
return state.select(name, {
|
||||
mode: "within-polygon",
|
||||
polygon
|
||||
});
|
||||
}
|
||||
|
||||
case "continuous metadata histogram start":
|
||||
case "continuous metadata histogram brush":
|
||||
case "continuous metadata histogram end": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
// action.selection: metadata name being selected
|
||||
// action.range: filter range, or null if deselected
|
||||
if (!action.range) {
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
const [lo, hi] = action.range;
|
||||
const newState = state.select(name, { mode: "range", lo, hi });
|
||||
return newState;
|
||||
}
|
||||
|
||||
case "categorical metadata filter select":
|
||||
case "categorical metadata filter deselect": {
|
||||
const { categoricalSelection } = nextSharedState;
|
||||
const cat = categoricalSelection[action.metadataField];
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "exact",
|
||||
values: ControlsHelpers.selectedValuesForCategory(cat)
|
||||
});
|
||||
}
|
||||
|
||||
case "categorical metadata filter none of these": {
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "none"
|
||||
});
|
||||
}
|
||||
|
||||
case "categorical metadata filter all of these": {
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "all"
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default CrossfilterReducer;
|
||||
@@ -1,27 +1,89 @@
|
||||
// jshint esversion: 6
|
||||
import { combineReducers, createStore, applyMiddleware } from "redux";
|
||||
import { createStore, applyMiddleware } from "redux";
|
||||
import thunk from "redux-thunk";
|
||||
import { composeWithDevTools } from "redux-devtools-extension";
|
||||
import updateURLMiddleware from "../middleware/updateURLMiddleware";
|
||||
import updateCellColors from "../middleware/updateCellColors";
|
||||
|
||||
import cascadeReducers from "./cascade";
|
||||
import undoable from "./undoable";
|
||||
import config from "./config";
|
||||
import universe from "./universe";
|
||||
import world from "./world";
|
||||
import categoricalSelection from "./categoricalSelection";
|
||||
import continuousSelection from "./continuousSelection";
|
||||
import crossfilter from "./crossfilter";
|
||||
import colors from "./colors";
|
||||
import differential from "./differential";
|
||||
import responsive from "./responsive";
|
||||
import controls from "./controls";
|
||||
import resetCache from "./resetCache";
|
||||
|
||||
const Reducer = combineReducers({
|
||||
config,
|
||||
responsive,
|
||||
controls,
|
||||
differential
|
||||
});
|
||||
const ignoredActions = new Set([
|
||||
// these actions will not affect history, ie, we will
|
||||
// not snapshot history upon these actions. These take
|
||||
// precedent over `clearHistoryUponActions`
|
||||
"url changed",
|
||||
"interface reset started",
|
||||
"initial data load start",
|
||||
"configuration load complete",
|
||||
"increment graph render counter",
|
||||
"window resize",
|
||||
|
||||
const store = createStore(
|
||||
Reducer,
|
||||
composeWithDevTools(
|
||||
applyMiddleware(thunk, updateURLMiddleware, updateCellColors)
|
||||
)
|
||||
"lasso started",
|
||||
|
||||
"request differential expression success",
|
||||
|
||||
"expression load start",
|
||||
"expression load success",
|
||||
"expression load error",
|
||||
|
||||
"continuous metadata histogram brush",
|
||||
"continuous metadata histogram end",
|
||||
|
||||
"request user defined gene started",
|
||||
"request user defined gene success",
|
||||
"request user defined gene error",
|
||||
"bulk user defined gene complete",
|
||||
"single user defined gene complete"
|
||||
]);
|
||||
|
||||
const clearOnActions = new Set([
|
||||
// history will be cleared when these actions occur
|
||||
"initial data load complete (universe exists)",
|
||||
"reset World to eq Universe",
|
||||
"initial data load error"
|
||||
]);
|
||||
|
||||
/* configuration for the undoable meta reducer */
|
||||
const undoableConfig = {
|
||||
historyLimit: 50, // maximum history size
|
||||
skipActionFilter: (state, action) => ignoredActions.has(action.type),
|
||||
clearOnActionFilter: (state, action) => clearOnActions.has(action.type)
|
||||
};
|
||||
|
||||
const Reducer = undoable(
|
||||
cascadeReducers([
|
||||
["config", config],
|
||||
["universe", universe],
|
||||
["world", world],
|
||||
["categoricalSelection", categoricalSelection],
|
||||
["continuousSelection", continuousSelection],
|
||||
["crossfilter", crossfilter],
|
||||
["colors", colors],
|
||||
["controls", controls],
|
||||
["differential", differential],
|
||||
["responsive", responsive],
|
||||
["resetCache", resetCache]
|
||||
]),
|
||||
[
|
||||
"world",
|
||||
"categoricalSelection",
|
||||
"continuousSelection",
|
||||
"crossfilter",
|
||||
"colors",
|
||||
"controls",
|
||||
"differential"
|
||||
],
|
||||
undoableConfig
|
||||
);
|
||||
|
||||
const store = createStore(Reducer, applyMiddleware(thunk));
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Reducer which caches derived state to be used in a reset
|
||||
*/
|
||||
|
||||
const ResetCacheReducer = (
|
||||
state = {
|
||||
world: null,
|
||||
crossfilter: null
|
||||
},
|
||||
action,
|
||||
nextSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { world, crossfilter } = nextSharedState;
|
||||
return {
|
||||
...state,
|
||||
world,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ResetCacheReducer;
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
A redo/undo meta reducer for Redux. Designed to work well with the cascadeReducer().
|
||||
|
||||
Requires three parameters:
|
||||
* reducer - a reducer, which MUST return an object as state.
|
||||
* undoableKeys - an array of object keys (strings). If any of these keys
|
||||
are in the object/state returned by the reducer, they will be treated as
|
||||
state to be made "undoable".
|
||||
* options - an optional object, which may contain the following parameters:
|
||||
* historyLimit: max number of historical states to remember (aka max undo depth)
|
||||
* skipActionFilter: filter function, (state, action) => bool. If it returns
|
||||
truthy, the current state will not be pushed onto the history stack.
|
||||
* clearOnActionFilter: filter function, (state, action) => bool. If it returns
|
||||
truthy, the history state will be cleared as part of handling this action.
|
||||
|
||||
skipActionFilter has precedence over clearOnActionFilter.
|
||||
|
||||
This meta reducer accepts three actions types:
|
||||
* @@undoable/undo - move back in history
|
||||
* @@undoable/redo - move forward in history
|
||||
* @@undoable/clear - clear history
|
||||
*/
|
||||
|
||||
const historyKeyPrefix = "@@undoable/";
|
||||
const pastKey = `${historyKeyPrefix}past`;
|
||||
const futureKey = `${historyKeyPrefix}future`;
|
||||
const defaultHistoryLimit = -100;
|
||||
|
||||
const Undoable = (reducer, undoableKeys, options = {}) => {
|
||||
let { historyLimit } = options;
|
||||
if (!historyLimit) historyLimit = defaultHistoryLimit;
|
||||
if (historyLimit > 0) historyLimit = -historyLimit;
|
||||
const skipActionFilter = options.skipActionFilter || (() => false);
|
||||
const clearOnActionFilter = options.clearOnActionFilter || (() => false);
|
||||
|
||||
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
|
||||
throw new Error("undoable keys array must be specified");
|
||||
const undoableKeysSet = new Set(undoableKeys);
|
||||
|
||||
function undo(currentState) {
|
||||
const past = currentState[pastKey];
|
||||
const future = currentState[futureKey];
|
||||
if (past.length === 0) return currentState;
|
||||
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||
undoableKeysSet.has(kv[0])
|
||||
);
|
||||
const newPast = [...past];
|
||||
const newState = newPast.pop();
|
||||
const newFuture = push(future, currentUndoableState);
|
||||
const nextState = {
|
||||
...currentState,
|
||||
...fromEntries(newState),
|
||||
[pastKey]: newPast,
|
||||
[futureKey]: newFuture
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function redo(currentState) {
|
||||
const past = currentState[pastKey] || [];
|
||||
const future = currentState[futureKey] || [];
|
||||
if (future.length === 0) return currentState;
|
||||
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||
undoableKeysSet.has(kv[0])
|
||||
);
|
||||
const newFuture = [...future];
|
||||
const newState = newFuture.pop();
|
||||
const newPast = push(past, currentUndoableState);
|
||||
const nextState = {
|
||||
...currentState,
|
||||
...fromEntries(newState),
|
||||
[pastKey]: newPast,
|
||||
[futureKey]: newFuture
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function clear(currentState) {
|
||||
return {
|
||||
...currentState,
|
||||
[pastKey]: [],
|
||||
[futureKey]: []
|
||||
};
|
||||
}
|
||||
|
||||
function skip(currentState, action) {
|
||||
const past = currentState[pastKey] || [];
|
||||
const res = reducer(currentState, action);
|
||||
return {
|
||||
...res,
|
||||
[pastKey]: past,
|
||||
[futureKey]: []
|
||||
};
|
||||
}
|
||||
|
||||
function save(currentState, action) {
|
||||
const past = currentState[pastKey] || [];
|
||||
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||
undoableKeysSet.has(kv[0])
|
||||
);
|
||||
const res = reducer(currentState, action);
|
||||
const newPast = push(past, currentUndoableState, historyLimit);
|
||||
const nextState = {
|
||||
...res,
|
||||
[pastKey]: newPast,
|
||||
[futureKey]: []
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
|
||||
return (
|
||||
currentState = {
|
||||
[pastKey]: [],
|
||||
[futureKey]: []
|
||||
},
|
||||
action
|
||||
) => {
|
||||
const aType = action.type;
|
||||
switch (aType) {
|
||||
case "@@undoable/undo": {
|
||||
return undo(currentState, action);
|
||||
}
|
||||
case "@@undoable/redo": {
|
||||
return redo(currentState, action);
|
||||
}
|
||||
case "@@undoable/clear": {
|
||||
return clear(currentState, action);
|
||||
}
|
||||
default: {
|
||||
if (skipActionFilter(currentState, action)) {
|
||||
return skip(currentState, action);
|
||||
}
|
||||
if (clearOnActionFilter(currentState, action)) {
|
||||
return clear(skip(currentState, action));
|
||||
}
|
||||
return save(currentState, action);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function push(arr, val, limit = undefined) {
|
||||
/*
|
||||
functional array push, with a max length limit to the new array.
|
||||
Like Array.push, except it returns new array and discards as needed
|
||||
to enforce the length limit.
|
||||
*/
|
||||
const narr = arr.slice(limit);
|
||||
narr.push(val);
|
||||
return narr;
|
||||
}
|
||||
|
||||
function fromEntries(arr) {
|
||||
/*
|
||||
Similar to Object.fromEntries, but only handles array.
|
||||
This could be replaced with the standard fucnction once it
|
||||
is widely available. As of 3/20/2019, it has not yet
|
||||
been released in the Chrome stable channel.
|
||||
*/
|
||||
const obj = {};
|
||||
for (let i = 0, l = arr.length; i < l; i += 1) {
|
||||
obj[arr[i][0]] = arr[i][1];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export default Undoable;
|
||||
@@ -0,0 +1,47 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import { ControlsHelpers } from "../util/stateManager";
|
||||
|
||||
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { universe } = action;
|
||||
return universe;
|
||||
}
|
||||
|
||||
case "expression load success": {
|
||||
let { varData } = state;
|
||||
|
||||
// Load new expression data into the varData dataframes, if
|
||||
// not already present.
|
||||
_.forEach(action.expressionData, (val, key) => {
|
||||
// If not already in universe.varData, save entire expression column
|
||||
if (!varData.hasCol(key)) {
|
||||
varData = varData.withCol(key, val);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune size of varData "cache" if getting out of hand....
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||
const allTheGenesWeNeed = _.uniq(
|
||||
[].concat(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
Object.keys(action.expressionData)
|
||||
)
|
||||
);
|
||||
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
|
||||
|
||||
return {
|
||||
...state,
|
||||
varData
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default Universe;
|
||||
@@ -0,0 +1,88 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import { World, ControlsHelpers } from "../util/stateManager";
|
||||
|
||||
const WorldReducer = (
|
||||
state = null,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { universe } = nextSharedState;
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
return world;
|
||||
}
|
||||
|
||||
case "reset World to eq Universe": {
|
||||
return prevSharedState.resetCache.world;
|
||||
}
|
||||
|
||||
case "set World to current selection": {
|
||||
/* Set viewable world to be the currently selected data */
|
||||
const world = World.createWorldFromCurrentSelection(
|
||||
action.universe,
|
||||
action.world,
|
||||
action.crossfilter
|
||||
);
|
||||
return world;
|
||||
}
|
||||
|
||||
case "expression load success": {
|
||||
const { universe } = nextSharedState;
|
||||
const universeVarData = universe.varData;
|
||||
let worldVarData = state.varData;
|
||||
|
||||
// Load new expression data into the varData dataframes, if
|
||||
// not already present.
|
||||
_.forEach(action.expressionData, (val, key) => {
|
||||
// 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(state, universe)) {
|
||||
worldValSlice = universeVarData
|
||||
.subset(state.obsAnnotations.rowIndex.keys(), [key], null)
|
||||
.icol(0)
|
||||
.asArray();
|
||||
}
|
||||
|
||||
// Now build world's varData dataframe
|
||||
worldVarData = worldVarData.withCol(
|
||||
key,
|
||||
worldValSlice,
|
||||
state.obsAnnotations.rowIndex
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune size of varData "cache" if getting out of hand....
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||
const allTheGenesWeNeed = _.uniq(
|
||||
[].concat(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
Object.keys(action.expressionData)
|
||||
)
|
||||
);
|
||||
worldVarData = ControlsHelpers.pruneVarDataCache(
|
||||
worldVarData,
|
||||
allTheGenesWeNeed
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
varData: worldVarData
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default WorldReducer;
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
Helper functions for the embedded graph colors
|
||||
*/
|
||||
import _ from "lodash";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic";
|
||||
import * as globals from "../../globals";
|
||||
import parseRGB from "../parseRGB";
|
||||
import finiteExtent from "../finiteExtent";
|
||||
|
||||
/*
|
||||
create new colors state object. Paramters:
|
||||
- world - current world object
|
||||
- mode - color-by mode. One of: null, "color by expression",
|
||||
"color by continuous metadata", "color by categorical metadata"
|
||||
-
|
||||
*/
|
||||
function createColors(world, colorMode = null, colorAccessor = null) {
|
||||
switch (colorMode) {
|
||||
case "color by categorical metadata": {
|
||||
return createColorsByCategoricalMetadata(world, colorAccessor);
|
||||
}
|
||||
case "color by continuous metadata": {
|
||||
return createColorsByContinuousMetadata(world, colorAccessor);
|
||||
}
|
||||
case "color by expression": {
|
||||
return createColorsByExpression(world, colorAccessor);
|
||||
}
|
||||
default: {
|
||||
const defaultCellColor = parseRGB(globals.defaultCellColor);
|
||||
return {
|
||||
rgb: new Array(world.nObs).fill(defaultCellColor),
|
||||
scale: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createColorsByCategoricalMetadata(world, accessor) {
|
||||
const { categories } = _.filter(world.schema.annotations.obs, {
|
||||
name: accessor
|
||||
})[0];
|
||||
|
||||
const scale = d3
|
||||
.scaleSequential(interpolateRainbow)
|
||||
.domain([0, categories.length]);
|
||||
|
||||
/* pre-create colors - much faster than doing it for each obs */
|
||||
const colors = categories.reduce((acc, cat, idx) => {
|
||||
acc[cat] = parseRGB(scale(idx));
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const rgb = new Array(world.nObs);
|
||||
const data = world.obsAnnotations.col(accessor).asArray();
|
||||
for (let i = 0, len = world.obsAnnotations.length; i < len; i += 1) {
|
||||
const cat = data[i];
|
||||
rgb[i] = colors[cat];
|
||||
}
|
||||
return { rgb, scale };
|
||||
}
|
||||
|
||||
function createColorsByContinuousMetadata(world, accessor) {
|
||||
const colorBins = 100;
|
||||
const col = world.obsAnnotations.col(accessor);
|
||||
const { min, max } = col.summarize();
|
||||
const scale = d3
|
||||
.scaleQuantile()
|
||||
.domain([min, max])
|
||||
.range(_.range(colorBins - 1, -1, -1));
|
||||
|
||||
/* pre-create colors - much faster than doing it for each obs */
|
||||
const colors = new Array(colorBins);
|
||||
for (let i = 0; i < colorBins; i += 1) {
|
||||
colors[i] = parseRGB(interpolateCool(i / colorBins));
|
||||
}
|
||||
|
||||
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
|
||||
const rgb = new Array(world.nObs);
|
||||
const data = col.asArray();
|
||||
for (let i = 0, len = world.obsAnnotations.length; i < len; i += 1) {
|
||||
const val = data[i];
|
||||
if (Number.isFinite(val)) {
|
||||
const c = scale(val);
|
||||
rgb[i] = colors[c];
|
||||
} else {
|
||||
rgb[i] = nonFiniteColor;
|
||||
}
|
||||
}
|
||||
return { rgb, scale };
|
||||
}
|
||||
|
||||
function createColorsByExpression(world, accessor) {
|
||||
const expression = world.varData.col(accessor).asArray();
|
||||
const colorBins = 100;
|
||||
const [min, max] = finiteExtent(expression);
|
||||
const scale = d3
|
||||
.scaleQuantile()
|
||||
.domain([min, max])
|
||||
.range(_.range(colorBins - 1, -1, -1));
|
||||
|
||||
/* pre-create colors - much faster than doing it for each obs */
|
||||
const colors = new Array(colorBins);
|
||||
for (let i = 0; i < colorBins; i += 1) {
|
||||
colors[i] = parseRGB(interpolateCool(i / colorBins));
|
||||
}
|
||||
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
|
||||
|
||||
const rgb = new Array(world.nObs);
|
||||
for (let i = 0, len = expression.length; i < len; i += 1) {
|
||||
const e = expression[i];
|
||||
if (Number.isFinite(e)) {
|
||||
const c = scale(e);
|
||||
rgb[i] = colors[c];
|
||||
} else {
|
||||
rgb[i] = nonFiniteColor;
|
||||
}
|
||||
}
|
||||
return { rgb, scale };
|
||||
}
|
||||
|
||||
export default createColors;
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName
|
||||
} from "../nameCreators";
|
||||
import * as World from "./world";
|
||||
|
||||
/*
|
||||
Selection state for categoricals are tracked in an Object that
|
||||
@@ -55,7 +54,7 @@ function topNCategories(summary) {
|
||||
return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)];
|
||||
}
|
||||
|
||||
export function createCategoricalSelectionState(state, world) {
|
||||
export function createCategoricalSelection(maxCategoryItems, world) {
|
||||
const res = {};
|
||||
_.forEach(world.obsAnnotations.colIndex.keys(), key => {
|
||||
const summary = world.obsAnnotations.col(key).summarize();
|
||||
@@ -64,7 +63,7 @@ export function createCategoricalSelectionState(state, world) {
|
||||
const isSelectableCategory =
|
||||
!isColorField &&
|
||||
key !== "name" &&
|
||||
summary.categories.length < state.maxCategoryItems;
|
||||
summary.categories.length < maxCategoryItems;
|
||||
if (isSelectableCategory) {
|
||||
const [categoryValues, categoryCounts] = topNCategories(summary);
|
||||
const categoryIndices = new Map(categoryValues.map((v, i) => [v, i]));
|
||||
@@ -86,7 +85,7 @@ export function createCategoricalSelectionState(state, world) {
|
||||
}
|
||||
|
||||
/*
|
||||
given a categoricalSelectionState, return the list of all category values
|
||||
given a categoricalSelection, return the list of all category values
|
||||
where selection state is true (ie, they are selected).
|
||||
*/
|
||||
export function selectedValuesForCategory(categorySelectionState) {
|
||||
@@ -98,29 +97,35 @@ export function selectedValuesForCategory(categorySelectionState) {
|
||||
}
|
||||
|
||||
/*
|
||||
build a crossfilter dimension map for all gene expression related dimensions.
|
||||
build a crossfilter dimensions for all gene expression related dimensions.
|
||||
*/
|
||||
export function createGenesDimMap(
|
||||
export function createGeneDimensions(
|
||||
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)
|
||||
};
|
||||
crossfilter = userDefinedGenes.reduce(
|
||||
(xflt, gene) =>
|
||||
xflt.addDimension(
|
||||
userDefinedDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
),
|
||||
crossfilter
|
||||
);
|
||||
crossfilter = diffexpGenes.reduce(
|
||||
(xflt, gene) =>
|
||||
xflt.addDimension(
|
||||
diffexpDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
),
|
||||
crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
export function pruneVarDataCache(varData, needed) {
|
||||
|
||||
@@ -14,7 +14,8 @@ This is all VERY tightly integrated with reducers and actions, and
|
||||
exists to support those concepts.
|
||||
*/
|
||||
|
||||
export { default as createColors } from "./colorHelpers";
|
||||
export * as Universe from "./universe";
|
||||
export * as World from "./world";
|
||||
export * as WorldUtil from "./worldUtil";
|
||||
export * as ControlsHelper from "./controlsHelpers";
|
||||
export * as ControlsHelpers from "./controlsHelpers";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
import _ from "lodash";
|
||||
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
|
||||
import Crossfilter from "../typedCrossfilter";
|
||||
import * as Dataframe from "../dataframe";
|
||||
|
||||
/*
|
||||
@@ -98,7 +96,7 @@ export function createWorldFromCurrentSelection(universe, world, crossfilter) {
|
||||
newWorld.varAnnotations = universe.varAnnotations;
|
||||
|
||||
/* now subset/cut obs */
|
||||
const mask = crossfilter.allFilteredMask();
|
||||
const mask = crossfilter.allSelectedMask();
|
||||
newWorld.obsAnnotations = world.obsAnnotations.isubsetMask(mask);
|
||||
newWorld.obsLayout = world.obsLayout.isubsetMask(mask);
|
||||
newWorld.nObs = newWorld.obsAnnotations.dims[0];
|
||||
@@ -139,63 +137,32 @@ function deduceDimensionType(attributes, fieldName) {
|
||||
return dimensionType;
|
||||
}
|
||||
|
||||
/*
|
||||
Return a crossfilter dimension for the specified world & named gene.
|
||||
|
||||
NOTE: this assumes that the expression data was already loaded,
|
||||
by calling an appropriate action creator.
|
||||
|
||||
Caller needs to *save* this dimension somewhere for it to be later used.
|
||||
Dimension must be destroyed by calling dimension.dispose()
|
||||
when it is no longer needed
|
||||
(it will not be garbage collected without this call)
|
||||
*/
|
||||
export function createVarDataDimension(world, crossfilter, name) {
|
||||
return crossfilter.dimension(
|
||||
Crossfilter.ScalarDimension,
|
||||
world.varData.col(name).asArray(),
|
||||
Float32Array
|
||||
);
|
||||
}
|
||||
|
||||
export function createObsDimensionMap(crossfilter, world) {
|
||||
export function createObsDimensions(crossfilter, world) {
|
||||
/*
|
||||
create and return a crossfilter dimension for every obs annotation
|
||||
for which we have a supported type.
|
||||
create and return a crossfilter with a dimension for every obs annotation
|
||||
for which we have a supported type, *except* 'name'
|
||||
*/
|
||||
const { schema, obsLayout, obsAnnotations } = world;
|
||||
const annoList = schema.annotations.obs.filter(anno => anno.name !== "name");
|
||||
crossfilter = annoList.reduce((xfltr, anno) => {
|
||||
const dimType = deduceDimensionType(anno, anno.name);
|
||||
const colData = obsAnnotations.col(anno.name).asArray();
|
||||
const name = obsAnnoDimensionName(anno.name);
|
||||
if (dimType === "enum") {
|
||||
return xfltr.addDimension(name, "enum", colData);
|
||||
}
|
||||
if (dimType) {
|
||||
return xfltr.addDimension(name, "scalar", colData, dimType);
|
||||
}
|
||||
return xfltr;
|
||||
}, crossfilter);
|
||||
|
||||
// Create a crossfilter dimension for all obs annotations *except* 'name'
|
||||
const dimensionMap = _(schema.annotations.obs)
|
||||
.filter(anno => anno.name !== "name")
|
||||
.transform((result, anno) => {
|
||||
const dimType = deduceDimensionType(anno, anno.name);
|
||||
const colData = obsAnnotations.col(anno.name).asArray();
|
||||
if (dimType === "enum") {
|
||||
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
|
||||
Crossfilter.EnumDimension,
|
||||
colData
|
||||
);
|
||||
} else if (dimType) {
|
||||
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
|
||||
Crossfilter.ScalarDimension,
|
||||
colData,
|
||||
dimType
|
||||
);
|
||||
} // else ignore the annotation
|
||||
}, {})
|
||||
.value();
|
||||
|
||||
/*
|
||||
Add crossfilter dimensions allowing filtering on layout
|
||||
*/
|
||||
dimensionMap[layoutDimensionName("XY")] = crossfilter.dimension(
|
||||
Crossfilter.SpatialDimension,
|
||||
return crossfilter.addDimension(
|
||||
layoutDimensionName("XY"),
|
||||
"spatial",
|
||||
obsLayout.col("X").asArray(),
|
||||
obsLayout.col("Y").asArray()
|
||||
);
|
||||
|
||||
return dimensionMap;
|
||||
}
|
||||
|
||||
export function worldEqUniverse(world, universe) {
|
||||
@@ -206,7 +173,7 @@ export function getSelectedByIndex(crossfilter) {
|
||||
/*
|
||||
return array of obsIndex, containing all selected obs/cells.
|
||||
*/
|
||||
const selected = crossfilter.allFilteredMask(); // array of bool-ish
|
||||
const selected = crossfilter.allSelectedMask(); // array of bool-ish
|
||||
const keys = crossfilter.data.rowIndex.keys(); // row keys, aka universe rowIndex
|
||||
|
||||
const set = new Int32Array(selected.length);
|
||||
|
||||
@@ -211,6 +211,19 @@ class BitArray {
|
||||
}
|
||||
}
|
||||
|
||||
// select range of indices on a dimension
|
||||
//
|
||||
selectFromRange(dim, range) {
|
||||
const col = dim >>> 5;
|
||||
const first = range[0];
|
||||
const last = range[1];
|
||||
const one = 1 << dim % 32;
|
||||
const offset = col * this.length;
|
||||
for (let i = first; i < last; i += 1) {
|
||||
this.bitarray[offset + i] |= one;
|
||||
}
|
||||
}
|
||||
|
||||
// select range of indices on a dimension, indirect through a sort map.
|
||||
// Indirect functions are used to map between sort and natural order.
|
||||
//
|
||||
@@ -225,6 +238,19 @@ class BitArray {
|
||||
}
|
||||
}
|
||||
|
||||
// deselect range of indices on a dimension
|
||||
//
|
||||
deselectFromRange(dim, range) {
|
||||
const col = dim >>> 5;
|
||||
const first = range[0];
|
||||
const last = range[1];
|
||||
const zero = ~(1 << dim % 32);
|
||||
const offset = col * this.length;
|
||||
for (let i = first; i < last; i += 1) {
|
||||
this.bitarray[offset + i] &= zero;
|
||||
}
|
||||
}
|
||||
|
||||
// deselect range of indices on a dimension, indirect through a sort map.
|
||||
//
|
||||
deselectIndirectFromRange(dim, indirect, range) {
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import { polygonContains } from "d3";
|
||||
|
||||
import PositiveIntervals from "./positiveIntervals";
|
||||
import BitArray from "./bitArray";
|
||||
import { sort } from "./sort";
|
||||
import {
|
||||
makeSortIndex,
|
||||
lowerBound,
|
||||
lowerBoundIndirect,
|
||||
upperBoundIndirect
|
||||
} from "./util";
|
||||
|
||||
class NotImplementedError extends Error {
|
||||
constructor(...params) {
|
||||
super(...params);
|
||||
|
||||
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, NotImplementedError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class ImmutableTypedCrossfilter {
|
||||
constructor(data, dimensions = {}, selectionCache = null) {
|
||||
/*
|
||||
Typically, parameter 'data' is one of:
|
||||
- Array of objects/records
|
||||
- Dataframe (util/dataframe)
|
||||
Other parameters are only used internally.
|
||||
|
||||
Object field description:
|
||||
- data: reference to the array of records in the crossfilter
|
||||
- selectionBitArray: bit array containing the flatted selection state
|
||||
of all dimensions. This is lazily created and is effectively
|
||||
a perfomance cache. Methods which return a new crossfilter,
|
||||
such as select(), addDimention() and delDimension(), will pass
|
||||
the cache forward to the new object, as the typical "immutable API"
|
||||
usage pattern is to retain the new crossfilter and discard the old.
|
||||
- dimensions: contains each dimension and its current state:
|
||||
- id: bit offset in the cached bit array
|
||||
- dim: the dimension object
|
||||
- name: the dimension name
|
||||
- selection: the dimension's current selection
|
||||
*/
|
||||
this.data = data;
|
||||
this.selectionCache = selectionCache; /* BitArray */
|
||||
this.dimensions = dimensions; /* name: { id, dim, name, selection } */
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.data.length;
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
dimensionNames() {
|
||||
/* return array of all dimensions (by name) */
|
||||
return Object.keys(this.dimensions);
|
||||
}
|
||||
|
||||
addDimension(name, type, ...rest) {
|
||||
/*
|
||||
Add a new dimension to this crossfilter, of type DimensionType.
|
||||
Remainder of parameters are dimension-type-specific.
|
||||
*/
|
||||
const { data, selectionCache } = this;
|
||||
|
||||
if (this.dimensions[name] !== undefined) {
|
||||
throw new Error(`Adding duplicate dimension name ${name}`);
|
||||
}
|
||||
|
||||
this.selectionCache = null; // pass ownership to new crossfilter
|
||||
|
||||
let id;
|
||||
if (selectionCache) {
|
||||
id = selectionCache.allocDimension();
|
||||
selectionCache.selectAll(id);
|
||||
}
|
||||
const DimensionType = DimTypes[type];
|
||||
const dim = new DimensionType(name, data, ...rest);
|
||||
const dimensions = {
|
||||
...this.dimensions,
|
||||
[name]: {
|
||||
id,
|
||||
dim,
|
||||
name,
|
||||
selection: dim.select({ mode: "all" })
|
||||
}
|
||||
};
|
||||
return new ImmutableTypedCrossfilter(data, dimensions, selectionCache);
|
||||
}
|
||||
|
||||
delDimension(name) {
|
||||
const { data, selectionCache } = this;
|
||||
const dimensions = { ...this.dimensions };
|
||||
if (dimensions[name] === undefined) {
|
||||
throw new ReferenceError(`Unable to delete unknown dimension ${name}`);
|
||||
}
|
||||
|
||||
const { id } = dimensions[name];
|
||||
delete dimensions[name];
|
||||
this.selectionCache = null; // pass ownership to new crossfilter
|
||||
if (selectionCache) {
|
||||
selectionCache.freeDimension(id);
|
||||
}
|
||||
return new ImmutableTypedCrossfilter(data, dimensions, selectionCache);
|
||||
}
|
||||
|
||||
select(name, spec) {
|
||||
/*
|
||||
select on named dimension, as indicated by `spec`. Spec is an object
|
||||
specifying the selection, and must contain at least a `mode` field.
|
||||
|
||||
Examples:
|
||||
select("foo", {mode: "all"});
|
||||
select("bar", {mode: "none"});
|
||||
select("mumble", {mode: "exact", values: "blue"});
|
||||
select("mumble", {mode: "exact", values: ["red", "green", "blue"]});
|
||||
select("blort", {mode: "range", lo: 0, hi: 999.99});
|
||||
*/
|
||||
const { data, selectionCache } = this;
|
||||
this.selectionCache = null;
|
||||
const dimensions = { ...this.dimensions };
|
||||
const { dim, id, selection: oldSelection } = dimensions[name];
|
||||
const newSelection = dim.select(spec);
|
||||
newSelection.ranges = PositiveIntervals.canonicalize(newSelection.ranges);
|
||||
dimensions[name] = { id, dim, name, selection: newSelection };
|
||||
ImmutableTypedCrossfilter._dimSelnHasUpdated(
|
||||
selectionCache,
|
||||
id,
|
||||
newSelection,
|
||||
oldSelection
|
||||
);
|
||||
return new ImmutableTypedCrossfilter(data, dimensions, selectionCache);
|
||||
}
|
||||
|
||||
static _dimSelnHasUpdated(selectionCache, id, newSeln, oldSeln) {
|
||||
/*
|
||||
Selection has updated from oldSeln to newSeln. Update the
|
||||
bit array if it exists. If not, we will lazy create it when
|
||||
needed.
|
||||
*/
|
||||
if (selectionCache) {
|
||||
/*
|
||||
if both new and old selection use the same index, we can
|
||||
perform an incremental update. If the index changed, we have
|
||||
to do a suboptimal full deselect/select.
|
||||
*/
|
||||
let adds;
|
||||
let dels;
|
||||
if (newSeln.index === oldSeln.index) {
|
||||
adds = PositiveIntervals.difference(newSeln.ranges, oldSeln.ranges);
|
||||
dels = PositiveIntervals.difference(oldSeln.ranges, newSeln.ranges);
|
||||
} else {
|
||||
// console.log("suboptimal selection update - index changed");
|
||||
adds = newSeln.ranges;
|
||||
dels = oldSeln.ranges;
|
||||
}
|
||||
|
||||
/*
|
||||
allow dimensions to return selected ranges in either dimension sort
|
||||
order (indirect via index), or in original record order.
|
||||
|
||||
If sort index exists in the dimension, assume sort ordered ranges.
|
||||
*/
|
||||
if (oldSeln.index) {
|
||||
dels.forEach(interval =>
|
||||
selectionCache.deselectIndirectFromRange(id, oldSeln.index, interval)
|
||||
);
|
||||
} else {
|
||||
dels.forEach(interval =>
|
||||
selectionCache.deselectFromRange(id, interval)
|
||||
);
|
||||
}
|
||||
|
||||
if (newSeln.index) {
|
||||
adds.forEach(interval =>
|
||||
selectionCache.selectIndirectFromRange(id, newSeln.index, interval)
|
||||
);
|
||||
} else {
|
||||
adds.forEach(interval => selectionCache.selectFromRange(id, interval));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getSelectionCache() {
|
||||
if (!this.selectionCache) {
|
||||
// console.log("...rebuilding crossfilter cache...");
|
||||
const selectionCache = new BitArray(this.data.length);
|
||||
Object.keys(this.dimensions).forEach(name => {
|
||||
const { selection } = this.dimensions[name];
|
||||
const id = selectionCache.allocDimension();
|
||||
this.dimensions[name].id = id;
|
||||
const { ranges, index } = selection;
|
||||
ranges.forEach(range => {
|
||||
if (index) {
|
||||
selectionCache.selectIndirectFromRange(id, index, range);
|
||||
} else {
|
||||
selectionCache.selectFromRange(id, range);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.selectionCache = selectionCache;
|
||||
}
|
||||
return this.selectionCache;
|
||||
}
|
||||
|
||||
allSelected() {
|
||||
/*
|
||||
return array of all records currently selected by all dimensions
|
||||
*/
|
||||
const selectionCache = this._getSelectionCache();
|
||||
const { data } = this;
|
||||
if (Array.isArray(data)) {
|
||||
const res = [];
|
||||
for (let i = 0, len = data.length; i < len; i += 1) {
|
||||
if (selectionCache.isSelected(i)) {
|
||||
res.push(data[i]);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
/* else, Dataframe-like */
|
||||
return data.isubsetMask(this.allSelectedMask());
|
||||
}
|
||||
|
||||
allSelectedMask() {
|
||||
/*
|
||||
return Uint8Array containing selection state (truthy/falsey) for each record.
|
||||
*/
|
||||
const selectionCache = this._getSelectionCache();
|
||||
return selectionCache.fillBySelection(
|
||||
new Uint8Array(this.data.length),
|
||||
1,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
countSelected() {
|
||||
/*
|
||||
return number of records selected on all dimensions
|
||||
*/
|
||||
const selectionCache = this._getSelectionCache();
|
||||
return selectionCache.selectionCount();
|
||||
}
|
||||
|
||||
isElementSelected(i) {
|
||||
/*
|
||||
return truthy/falsey if this record is selected on all dimensions
|
||||
*/
|
||||
const selectionCache = this._getSelectionCache();
|
||||
return selectionCache.isSelected(i);
|
||||
}
|
||||
|
||||
fillByIsSelected(array, selectedValue, deselectedValue) {
|
||||
/*
|
||||
fill array with one of two values, based upon selection state.
|
||||
*/
|
||||
const selectionCache = this._getSelectionCache();
|
||||
return selectionCache.fillBySelection(
|
||||
array,
|
||||
selectedValue,
|
||||
deselectedValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Base dimension object.
|
||||
|
||||
A Dimension is an index, accessed via a select() method. The protocol
|
||||
for a dimension:
|
||||
- constructor - first param is name, remainder is whatever params are
|
||||
required to initialize the dimension.
|
||||
- select - one and only param is the selection specifier. Returns an
|
||||
array of record IDs.
|
||||
- name - the dimension name/label.
|
||||
*/
|
||||
class _ImmutableBaseDimension {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
select(spec) {
|
||||
const { mode } = spec;
|
||||
if (mode === undefined) {
|
||||
throw new Error("select spec does not contain 'mode'");
|
||||
}
|
||||
throw new Error(`select mode ${mode} not implemented`);
|
||||
}
|
||||
/* eslint-enable class-methods-use-this */
|
||||
}
|
||||
|
||||
class ImmutableScalarDimension extends _ImmutableBaseDimension {
|
||||
constructor(name, data, value, ValueArrayType) {
|
||||
super(name);
|
||||
|
||||
// Three modes - caller can provide a pre-created value array,
|
||||
// a map function which will create it, or another array which
|
||||
// will used with an identity map function.
|
||||
let array;
|
||||
if (value instanceof ValueArrayType) {
|
||||
// user has provided the final typed array - just use it
|
||||
if (value.length !== data.length) {
|
||||
throw new RangeError(
|
||||
"ScalarDimension values length must equal crossfilter data record count"
|
||||
);
|
||||
}
|
||||
array = value;
|
||||
} else if (value instanceof Function) {
|
||||
// Create value array from user-provided map function.
|
||||
array = this._createValueArray(
|
||||
data,
|
||||
value,
|
||||
new ValueArrayType(data.length)
|
||||
);
|
||||
} else if (isArrayOrTypedArray(value)) {
|
||||
// Create value array from user-provided array. Typically used
|
||||
// only by enumerated dimensions
|
||||
array = this._createValueArray(
|
||||
data,
|
||||
i => value[i],
|
||||
new ValueArrayType(data.length)
|
||||
);
|
||||
} else {
|
||||
throw new NotImplementedError(
|
||||
"dimension value must be function or value array type"
|
||||
);
|
||||
}
|
||||
this.value = array;
|
||||
|
||||
// create sort index
|
||||
this.index = makeSortIndex(array);
|
||||
}
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
_createValueArray(data, mapf, array) {
|
||||
// create dimension value array
|
||||
const len = data.length;
|
||||
const larray = array;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
larray[i] = mapf(i, data);
|
||||
}
|
||||
return larray;
|
||||
}
|
||||
/* eslint-enable class-methods-use-this */
|
||||
|
||||
select(spec) {
|
||||
const { mode } = spec;
|
||||
const { index } = this;
|
||||
switch (mode) {
|
||||
case "all":
|
||||
return { ranges: [[0, this.value.length]], index };
|
||||
case "none":
|
||||
return { ranges: [], index };
|
||||
case "exact":
|
||||
return this.selectExact(spec);
|
||||
case "range":
|
||||
return this.selectRange(spec);
|
||||
default:
|
||||
return super.select(spec);
|
||||
}
|
||||
}
|
||||
|
||||
selectExact(spec) {
|
||||
const { value, index } = this;
|
||||
let { values } = spec;
|
||||
if (!Array.isArray(values)) {
|
||||
values = [values];
|
||||
}
|
||||
const ranges = [];
|
||||
for (let v = 0, len = values.length; v < len; v += 1) {
|
||||
const r = [
|
||||
lowerBoundIndirect(value, index, values[v], 0, value.length),
|
||||
upperBoundIndirect(value, index, values[v], 0, value.length)
|
||||
];
|
||||
if (r[0] <= r[1]) {
|
||||
ranges.push(r);
|
||||
}
|
||||
}
|
||||
return { ranges, index };
|
||||
}
|
||||
|
||||
selectRange(spec) {
|
||||
const { value, index } = this;
|
||||
/* [lo, hi) */
|
||||
const { lo, hi } = spec;
|
||||
const ranges = [];
|
||||
const r = [
|
||||
lowerBoundIndirect(value, index, lo, 0, value.length),
|
||||
lowerBoundIndirect(value, index, hi, 0, value.length)
|
||||
];
|
||||
if (r[0] < r[1]) ranges.push(r);
|
||||
return { ranges, index };
|
||||
}
|
||||
}
|
||||
|
||||
class ImmutableEnumDimension extends ImmutableScalarDimension {
|
||||
constructor(name, data, value) {
|
||||
super(name, data, value, Uint32Array);
|
||||
}
|
||||
|
||||
_createValueArray(data, mapf, array) {
|
||||
const len = data.length;
|
||||
const larray = array;
|
||||
|
||||
// create enumeration table - mapping between the value
|
||||
// and the enum.
|
||||
const s = new Set();
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
s.add(mapf(i, data));
|
||||
}
|
||||
const enumIndex = sort(Array.from(s));
|
||||
this.enumIndex = enumIndex;
|
||||
|
||||
// create dimension value array
|
||||
const enumLen = enumIndex.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const v = mapf(i, data);
|
||||
const e = lowerBound(enumIndex, v, 0, enumLen);
|
||||
larray[i] = e;
|
||||
}
|
||||
return larray;
|
||||
}
|
||||
|
||||
selectExact(spec) {
|
||||
const { enumIndex } = this;
|
||||
const { values } = spec;
|
||||
return super.selectExact({
|
||||
mode: spec.mode,
|
||||
values: values.map(v => lowerBound(enumIndex, v, 0, enumIndex.length))
|
||||
});
|
||||
}
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
selectRange() {
|
||||
throw new Error("range selection unsupported on Enumerated dimension");
|
||||
}
|
||||
/* eslint-enable class-methods-use-this */
|
||||
}
|
||||
|
||||
class ImmutableSpatialDimension extends _ImmutableBaseDimension {
|
||||
constructor(name, data, X, Y) {
|
||||
super(name);
|
||||
|
||||
if (X.length !== Y.length && X.length !== 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);
|
||||
}
|
||||
|
||||
select(spec) {
|
||||
const { mode } = spec;
|
||||
switch (mode) {
|
||||
case "all":
|
||||
return { ranges: [[0, this.X.length]], index: null };
|
||||
case "none":
|
||||
return { ranges: [], index: null };
|
||||
case "within-rect":
|
||||
return this.selectWithinRect(spec);
|
||||
case "within-polygon":
|
||||
return this.selectWithinPolygon(spec);
|
||||
default:
|
||||
return super.select(spec);
|
||||
}
|
||||
}
|
||||
|
||||
selectWithinRect(spec) {
|
||||
/*
|
||||
{ mode: "within-rect", x0: 1, y0: 0, x1: 3, y1: 9 }
|
||||
*/
|
||||
const { x0, y0, x1, y1 } = spec;
|
||||
const { X, Y } = this;
|
||||
const ranges = [];
|
||||
let start = -1;
|
||||
for (let i = 0, l = X.length; i < l; i += 1) {
|
||||
const x = X[i];
|
||||
const y = Y[i];
|
||||
const inside = x0 <= x && x < x1 && y0 <= y && y < y1;
|
||||
if (inside && start === -1) start = i;
|
||||
if (!inside && start !== -1) {
|
||||
ranges.push([start, i]);
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
if (start !== -1) ranges.push([start, X.length]);
|
||||
return { ranges, index: null };
|
||||
}
|
||||
|
||||
/*
|
||||
Relatively brute force filter by polygon.
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
selectWithinPolygon(spec) {
|
||||
/*
|
||||
{ mode: "within-polygon", polygon: [ [x0, y0], ... ] }
|
||||
*/
|
||||
const { polygon } = spec;
|
||||
const [minX, minY, maxX, maxY] = polygonBoundingBox(polygon);
|
||||
const { X, Y, Xindex, Yindex } = this;
|
||||
const { length } = X;
|
||||
let slice;
|
||||
let index;
|
||||
if (maxY - minY > maxX - minX) {
|
||||
slice = [
|
||||
lowerBoundIndirect(X, Xindex, minX, 0, length),
|
||||
lowerBoundIndirect(X, Xindex, maxX, 0, length)
|
||||
];
|
||||
index = Xindex;
|
||||
} else {
|
||||
slice = [
|
||||
lowerBoundIndirect(Y, Yindex, minY, 0, length),
|
||||
lowerBoundIndirect(Y, Yindex, maxY, 0, length)
|
||||
];
|
||||
index = Yindex;
|
||||
}
|
||||
|
||||
const ranges = [];
|
||||
let start = -1;
|
||||
for (let i = slice[0], e = slice[1]; i < e; i += 1) {
|
||||
const rid = index[i];
|
||||
const x = X[rid];
|
||||
const y = Y[rid];
|
||||
const inside =
|
||||
minX <= x &&
|
||||
x < maxX &&
|
||||
minY <= y &&
|
||||
y < maxY &&
|
||||
withinPolygon(polygon, x, y);
|
||||
|
||||
if (inside && start === -1) start = i;
|
||||
if (!inside && start !== -1) {
|
||||
ranges.push([start, i]);
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
if (start !== -1) ranges.push([start, slice[1]]);
|
||||
return { ranges, index };
|
||||
}
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
export const DimTypes = {
|
||||
scalar: ImmutableScalarDimension,
|
||||
enum: ImmutableEnumDimension,
|
||||
spatial: ImmutableSpatialDimension
|
||||
};
|
||||
|
||||
function isArrayOrTypedArray(x) {
|
||||
return (
|
||||
Array.isArray(x) ||
|
||||
(ArrayBuffer.isView(x) &&
|
||||
Object.prototype.toString.call(x) !== "[object DataView]")
|
||||
);
|
||||
}
|
||||
|
||||
/* return bounding box of the polygon */
|
||||
function polygonBoundingBox(polygon) {
|
||||
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 = polygon.length; i < l; i += 1) {
|
||||
const point = polygon[i];
|
||||
const [x, y] = point;
|
||||
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];
|
||||
}
|
||||
|
||||
function withinPolygon(polygon, x, y) {
|
||||
// TODO XXX replace
|
||||
return polygonContains(polygon, [x, y]);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
/*
|
||||
Typedarray Crossfilter - a re-implementation of a subset of crossfilter, with
|
||||
Crossfilter - a re-implementation of a subset of crossfilter, with
|
||||
time/space optimizations predicated upon the following assumptions:
|
||||
- dimensions are uniformly typed, and all values must be of that type
|
||||
- dimension values must be a primitive type (int, float, string). Arrays
|
||||
@@ -11,14 +9,18 @@ time/space optimizations predicated upon the following assumptions:
|
||||
want to do that, you have to create the new crossfilter, using the new
|
||||
data, from scratch.
|
||||
|
||||
The actual backing store for a dimension is a TypedArray, enabling significant
|
||||
In addition, this implementation is easier to use with a "redux" style
|
||||
app, as all operations on the crossfilter are immutable (ie, return a
|
||||
new crossfilter).
|
||||
|
||||
The actual backing store for a dimension is a TypedArray, enabling
|
||||
performance improvements over the original crossfilter.
|
||||
|
||||
There are also a handful of new methods, primarily to take advantage of the
|
||||
performance (eg, crossfilter.fillBySelection)
|
||||
|
||||
Helpful documents (this module tries to follow the original API as much
|
||||
as is feasable):
|
||||
Helpful documents (this module follows similar concepts as the original,
|
||||
but deviates from the API):
|
||||
https://github.com/square/crossfilter/
|
||||
http://square.github.io/crossfilter/
|
||||
|
||||
@@ -26,766 +28,7 @@ There is also a newer, community supported fork of crossfilter, with a
|
||||
more complex API. In a few cases, elements of that API were incorporated.
|
||||
https://github.com/square/crossfilter/
|
||||
|
||||
See test cases for some concrete examples.
|
||||
*/
|
||||
// XXX replace
|
||||
import { polygonContains } from "d3";
|
||||
|
||||
import PositiveIntervals from "./positiveIntervals";
|
||||
import BitArray from "./bitArray";
|
||||
import {
|
||||
makeSortIndex,
|
||||
lowerBound,
|
||||
lowerBoundIndirect,
|
||||
upperBoundIndirect
|
||||
} from "./util";
|
||||
|
||||
function isArrayOrTypedArray(x) {
|
||||
return (
|
||||
Array.isArray(x) ||
|
||||
(ArrayBuffer.isView(x) &&
|
||||
Object.prototype.toString.call(x) !== "[object DataView]")
|
||||
);
|
||||
}
|
||||
|
||||
class NotImplementedError extends Error {
|
||||
constructor(...params) {
|
||||
super(...params);
|
||||
|
||||
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, NotImplementedError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TypedCrossfilter {
|
||||
constructor(data) {
|
||||
/*
|
||||
Typically, data is one of:
|
||||
- Array of objects/records
|
||||
- Dataframe (util/dataframe)
|
||||
*/
|
||||
this.data = data;
|
||||
|
||||
// filters: array of { id, dimension }
|
||||
this.filters = [];
|
||||
this.selection = new BitArray(data.length);
|
||||
this.updateTime = 0;
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.data.length;
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/*
|
||||
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 dim = new DimensionType(this, id, ...rest);
|
||||
this.filters.push({ id, dim });
|
||||
dim.filterAll();
|
||||
return dim;
|
||||
}
|
||||
|
||||
_freeDimension(id) {
|
||||
this.selection.freeDimension(id);
|
||||
this.filters = this.filters.filter(f => f._id !== id);
|
||||
}
|
||||
|
||||
// return array of all records that are selected/filtered
|
||||
// by all dimensions.
|
||||
allFiltered() {
|
||||
const { data, selection } = this;
|
||||
if (Array.isArray(data)) {
|
||||
const res = [];
|
||||
for (let i = 0, len = data.length; i < len; i += 1) {
|
||||
if (selection.isSelected(i)) {
|
||||
res.push(data[i]);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
return this.selection.selectionCount();
|
||||
}
|
||||
|
||||
isElementFiltered(i) {
|
||||
return this.selection.isSelected(i);
|
||||
}
|
||||
|
||||
// fill array with one of two values, based upon selection state
|
||||
fillByIsFiltered(array, selectedValue, deselectedValue) {
|
||||
return this.selection.fillBySelection(
|
||||
array,
|
||||
selectedValue,
|
||||
deselectedValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Base dimension type - not exported.
|
||||
class _Dimension {
|
||||
constructor(xfltr, id) {
|
||||
this.crossfilter = xfltr;
|
||||
this._id = id;
|
||||
this.groups = [];
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.crossfilter._freeDimension(this._id);
|
||||
return this;
|
||||
}
|
||||
|
||||
id() {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
_filterUpdate() {
|
||||
this.crossfilter.updateTime += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Scalar dimension type - value must be a scalar type (eg, int, float),
|
||||
// and value array must be a TypedArray.
|
||||
//
|
||||
class ScalarDimension extends _Dimension {
|
||||
constructor(xfltr, id, value, ValueArrayType) {
|
||||
super(xfltr, id);
|
||||
|
||||
// current selection filter, expressed as PostiveIntervals.
|
||||
this.currentFilter = [];
|
||||
|
||||
// Two modes - caller can provide a pre-created value array,
|
||||
// or a map function which will create it.
|
||||
let array;
|
||||
if (value instanceof ValueArrayType) {
|
||||
// user has provided the final typed array - just use it
|
||||
if (value.length !== this.crossfilter.data.length) {
|
||||
throw new RangeError(
|
||||
"ScalarDimension values length must equal crossfilter data record count"
|
||||
);
|
||||
}
|
||||
array = value;
|
||||
} else if (value instanceof Function) {
|
||||
// Create value array from user-provided map function.
|
||||
array = this._createValueArray(
|
||||
value,
|
||||
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 {
|
||||
throw new NotImplementedError(
|
||||
"dimension value must be function or value array type"
|
||||
);
|
||||
}
|
||||
this.value = array;
|
||||
|
||||
// create sort index
|
||||
this.index = makeSortIndex(array);
|
||||
}
|
||||
|
||||
_createValueArray(value, array) {
|
||||
// create dimension value array
|
||||
const { data } = this.crossfilter;
|
||||
const len = data.length;
|
||||
const larray = array;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
larray[i] = value(i, data);
|
||||
}
|
||||
return larray;
|
||||
}
|
||||
|
||||
// Argument is an array of intervals indicating records newly selected/filtered
|
||||
//
|
||||
_updateFilters(newFilter) {
|
||||
const cNewFilter = PositiveIntervals.canonicalize(newFilter);
|
||||
|
||||
const adds = PositiveIntervals.difference(cNewFilter, this.currentFilter);
|
||||
const dels = PositiveIntervals.difference(this.currentFilter, cNewFilter);
|
||||
|
||||
this.crossfilter.filters.forEach(f =>
|
||||
f.dim.groups.forEach(grp => grp._updateReduceDel(this, dels))
|
||||
);
|
||||
|
||||
dels.forEach(interval =>
|
||||
this.crossfilter.selection.deselectIndirectFromRange(
|
||||
this._id,
|
||||
this.index,
|
||||
interval
|
||||
)
|
||||
);
|
||||
|
||||
adds.forEach(interval =>
|
||||
this.crossfilter.selection.selectIndirectFromRange(
|
||||
this._id,
|
||||
this.index,
|
||||
interval
|
||||
)
|
||||
);
|
||||
|
||||
this.crossfilter.filters.forEach(f =>
|
||||
f.dim.groups.forEach(grp => grp._updateReduceAdd(this, adds))
|
||||
);
|
||||
|
||||
this.currentFilter = cNewFilter;
|
||||
this._filterUpdate();
|
||||
}
|
||||
|
||||
// filter by value - exact match
|
||||
filterExact(value) {
|
||||
const newFilter = [
|
||||
lowerBoundIndirect(this.value, this.index, value, 0, this.value.length),
|
||||
upperBoundIndirect(this.value, this.index, value, 0, this.value.length)
|
||||
];
|
||||
if (newFilter[0] <= newFilter[1]) {
|
||||
this._updateFilters([newFilter]);
|
||||
} else {
|
||||
this._updateFilters([]);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// filter by a set of values, eg. enum.
|
||||
filterEnum(values) {
|
||||
const newFilter = [];
|
||||
for (let v = 0, len = values.length; v < len; v += 1) {
|
||||
const intv = [
|
||||
lowerBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
values[v],
|
||||
0,
|
||||
this.value.length
|
||||
),
|
||||
upperBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
values[v],
|
||||
0,
|
||||
this.value.length
|
||||
)
|
||||
];
|
||||
if (intv[0] <= intv[1]) newFilter.push(intv);
|
||||
}
|
||||
this._updateFilters(newFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
// filter by value range [lo, hi)
|
||||
// lo: inclusive, hi: exclusive
|
||||
filterRange(range) {
|
||||
const newFilter = [];
|
||||
const intv = [
|
||||
lowerBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
range[0],
|
||||
0,
|
||||
this.value.length
|
||||
),
|
||||
upperBoundIndirect(this.value, this.index, range[1], 0, this.value.length)
|
||||
];
|
||||
if (intv[0] < intv[1]) newFilter.push(intv);
|
||||
this._updateFilters(newFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
// select all - equivalent of selecting all in this dimension
|
||||
filterAll() {
|
||||
this._updateFilters([[0, this.value.length]]);
|
||||
return this;
|
||||
}
|
||||
|
||||
// select none
|
||||
filterNone() {
|
||||
this._updateFilters([]);
|
||||
}
|
||||
|
||||
// return top k records, starting with offset, in descending order.
|
||||
// Order is this dimension's sort order
|
||||
top(k, offset = 0) {
|
||||
const { data, selection } = this.crossfilter;
|
||||
const { index } = this;
|
||||
const len = index.length;
|
||||
const ret = [];
|
||||
let i = 0;
|
||||
let skip = 0;
|
||||
let found = 0;
|
||||
|
||||
// skip up to offset records
|
||||
for (i = len - 1; i >= 0 && skip < offset; i -= 1) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
skip += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// grab up to k records
|
||||
for (; i >= 0 && found < k; i -= 1) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
ret.push(data[index[i]]);
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// return bottom k records, starting with offset, in ascending order.
|
||||
// Order is this dimension's sort order
|
||||
bottom(k, offset = 0) {
|
||||
const { data, selection } = this.crossfilter;
|
||||
const { index } = this;
|
||||
const len = index.length;
|
||||
const ret = [];
|
||||
let skip = 0;
|
||||
let found = 0;
|
||||
let i = 0;
|
||||
|
||||
// skip up to offset records
|
||||
for (i = 0; i < len && skip < offset; i += 1) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
skip += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// grab up to k records
|
||||
for (; i < len && found < k; i += 1) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
ret.push(data[index[i]]);
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
group(groupValue) {
|
||||
const grp = new ScalarGroup(groupValue, this.value.constructor, this);
|
||||
this.groups.push(grp);
|
||||
return grp;
|
||||
}
|
||||
|
||||
_freeGroup(group) {
|
||||
this.groups = this.groups.filter(e => e !== group);
|
||||
}
|
||||
}
|
||||
|
||||
// Ordered enumeration - supports any sortable enumerable type, eg,
|
||||
// strings, which can be mapped into an fixed numeric range [0..n).
|
||||
//
|
||||
class EnumDimension extends ScalarDimension {
|
||||
constructor(xfltr, id, value) {
|
||||
super(xfltr, id, value, Uint32Array);
|
||||
}
|
||||
|
||||
_createValueArray(value, array) {
|
||||
const { data } = this.crossfilter;
|
||||
const len = data.length;
|
||||
const larray = array;
|
||||
|
||||
// create enumeration table - mapping between the value
|
||||
// and the enum.
|
||||
const s = new Set();
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
s.add(value(i, data));
|
||||
}
|
||||
this.enumIndex = Array.from(s);
|
||||
this.enumIndex.sort();
|
||||
|
||||
// create dimension value array
|
||||
const enumLen = this.enumIndex.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const v = value(i, data);
|
||||
const e = lowerBound(this.enumIndex, v, 0, enumLen);
|
||||
larray[i] = e;
|
||||
}
|
||||
return larray;
|
||||
}
|
||||
|
||||
filterExact(value) {
|
||||
return super.filterExact(
|
||||
lowerBound(this.enumIndex, value, 0, this.enumIndex.length)
|
||||
);
|
||||
}
|
||||
|
||||
filterEnum(values) {
|
||||
return super.filterEnum(
|
||||
values.map(v => lowerBound(this.enumIndex, v, 0, this.enumIndex.length))
|
||||
);
|
||||
}
|
||||
|
||||
filterRange(range) {
|
||||
return super.filterEnum(
|
||||
range.map(v => lowerBound(this.enumIndex, v, 0, this.enumIndex.length))
|
||||
);
|
||||
}
|
||||
|
||||
group(groupValue) {
|
||||
const grp = new EnumGroup(groupValue, this.value.constructor, this);
|
||||
this.groups.push(grp);
|
||||
return grp;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
//
|
||||
class ScalarGroup {
|
||||
constructor(groupValue, groupValueType, dimension) {
|
||||
// parent dimension
|
||||
this.dimension = dimension;
|
||||
|
||||
// generate group names from dimension values
|
||||
this.mapValue = this.constructor._map(
|
||||
groupValue,
|
||||
groupValueType,
|
||||
dimension
|
||||
);
|
||||
|
||||
// group index is mapping from data record index to group index
|
||||
this.groupIndex = new Uint32Array(dimension.crossfilter.data.length);
|
||||
|
||||
// default to counting
|
||||
this.reduceCount();
|
||||
|
||||
// Creates this.groups
|
||||
this._reduce();
|
||||
}
|
||||
|
||||
// internal support function - map all dimension values to group values.
|
||||
//
|
||||
static _map(groupValue, GroupValueType, dimension) {
|
||||
// groupValue is optional. Defaults to identity. Used to perform
|
||||
// initial map operation.
|
||||
//
|
||||
// identity: save some memory...
|
||||
if (groupValue === undefined) return dimension.value;
|
||||
|
||||
const data = dimension.value;
|
||||
const len = data.length;
|
||||
const mapValue = new GroupValueType(dimension.value.length);
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
mapValue[i] = groupValue(data[i]);
|
||||
}
|
||||
return mapValue;
|
||||
}
|
||||
|
||||
// Update the group reduction incrementally. Called when *any* dimension filter
|
||||
// changes. Guaranteed to be called AFTER the crossfilter is updated.
|
||||
//
|
||||
// Arguments:
|
||||
// * dim: the dimension that is changing
|
||||
// * intv: interval list of newly selected values on `dim` (adds)
|
||||
//
|
||||
_updateReduceAdd(dim, intv) {
|
||||
// ignore updates to self, as we don't reduce inclusive of our filter
|
||||
if (dim === this.dimension || intv.length === 0) return;
|
||||
|
||||
// Each item in the range was just added to `dim`. It was NOT previously
|
||||
// selected - reduceAdd if it is now selected.
|
||||
const { data, selection } = this.dimension.crossfilter;
|
||||
intv.forEach(rng => {
|
||||
for (let r = rng[0]; r < rng[1]; r += 1) {
|
||||
const i = dim.index[r];
|
||||
if (selection.isSelectedIgnoringDim(i, this.dimension.id())) {
|
||||
const group = this.groups[this.groupIndex[i]];
|
||||
group.value = this.reduceAdd(group.value, data[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update the group reduction incrementally. Called when *any* dimension filter
|
||||
// changes. Guaranteed to be called BEFORE the crossfilter is updated.
|
||||
//
|
||||
// Arguments:
|
||||
// * dim: the dimension that is changing
|
||||
// * intv: interval list of previously selected values on `dim` (dels)
|
||||
//
|
||||
_updateReduceDel(dim, intv) {
|
||||
// ignore updates to self, as we don't reduce inclusive of our filter
|
||||
if (dim === this.dimension || intv.length === 0) return;
|
||||
|
||||
// Each item in the range will be remved from `dim`. reduceRemove if it
|
||||
// is currently selected.
|
||||
const { data, selection } = this.dimension.crossfilter;
|
||||
intv.forEach(rng => {
|
||||
for (let r = rng[0]; r < rng[1]; r += 1) {
|
||||
const i = dim.index[r];
|
||||
if (selection.isSelectedIgnoringDim(i, this.dimension.id())) {
|
||||
const group = this.groups[this.groupIndex[i]];
|
||||
group.value = this.reduceRemove(group.value, data[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reduce the entire data set, creating both the group index and the
|
||||
// groups data.
|
||||
//
|
||||
_reduce() {
|
||||
const { dimension } = this;
|
||||
const { data } = dimension.crossfilter;
|
||||
|
||||
// Create groups
|
||||
const groupNames = new Set(this.mapValue);
|
||||
this.groups = [];
|
||||
const groupIndexByName = {};
|
||||
groupNames.forEach(name => {
|
||||
this.groups.push({ key: name, value: this.reduceInitial() });
|
||||
groupIndexByName[name] = this.groups.length - 1;
|
||||
});
|
||||
|
||||
// Create groupIndex - index map between data record index and group index
|
||||
for (let i = 0, len = this.mapValue.length; i < len; i += 1) {
|
||||
this.groupIndex[i] = groupIndexByName[this.mapValue[i]];
|
||||
}
|
||||
|
||||
// reduce all filtered records, IGNORING the current dimension's filter
|
||||
const { selection } = dimension.crossfilter;
|
||||
for (let i = 0, len = data.length; i < len; i += 1) {
|
||||
if (selection.isSelectedIgnoringDim(i, dimension.id())) {
|
||||
const group = this.groups[this.groupIndex[i]];
|
||||
group.value = this.reduceAdd(group.value, data[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.dimension._freeGroup(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
// return number of distinct values in the group, independent of any filters.
|
||||
//
|
||||
size() {
|
||||
return this.groups.length;
|
||||
}
|
||||
|
||||
// Set the reduce functions and return the grouping.
|
||||
//
|
||||
reduce(add, remove, initial) {
|
||||
this.reduceAdd = add;
|
||||
this.reduceRemove = remove;
|
||||
this.reduceInitial = initial;
|
||||
this._reduce();
|
||||
return this;
|
||||
}
|
||||
|
||||
// set the reduce functions to count records.
|
||||
reduceCount() {
|
||||
return this.reduce(p => p + 1, p => p - 1, () => 0);
|
||||
}
|
||||
|
||||
// set the reduce functions to sum records using specified value accessor.
|
||||
//
|
||||
reduceSum(value) {
|
||||
return this.reduce((p, v) => p + value(v), (p, v) => p - value(v), () => 0);
|
||||
}
|
||||
|
||||
all() {
|
||||
const res = [...this.groups];
|
||||
res.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
class EnumGroup extends ScalarGroup {
|
||||
static _map(groupValue, groupValueType, dimension) {
|
||||
// groupValue is optional. Defaults to identity. Used to perform
|
||||
// initial map operation.
|
||||
//
|
||||
// identity: save some memory
|
||||
if (groupValue === undefined) return dimension.value;
|
||||
|
||||
// non-identity mapping unsupported for EnumDimension/EnumGroup.
|
||||
// XXX: this could be implemented, but would require another index
|
||||
// array to map from the group names/keys back to the dimension values.
|
||||
// With this, we just rely on the dimensions `enumIndex` to map from
|
||||
// enumeration value to the record.
|
||||
throw new NotImplementedError("enumerated group mapping not implemented");
|
||||
}
|
||||
|
||||
all() {
|
||||
const res = [];
|
||||
this.groups.forEach(e =>
|
||||
res.push({
|
||||
// XXX: assumes identity group map - see comment in _map()
|
||||
key: this.dimension.enumIndex[e.key],
|
||||
value: e.value
|
||||
})
|
||||
);
|
||||
res.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for backwards compat with crossfilter.
|
||||
//
|
||||
function crossfilter(data) {
|
||||
return new TypedCrossfilter(data);
|
||||
}
|
||||
|
||||
crossfilter.PositiveIntervals = PositiveIntervals;
|
||||
crossfilter.BitArray = BitArray;
|
||||
crossfilter.TypedCrossfilter = TypedCrossfilter;
|
||||
crossfilter.ScalarDimension = ScalarDimension;
|
||||
crossfilter.EnumDimension = EnumDimension;
|
||||
crossfilter.SpatialDimension = SpatialDimension;
|
||||
|
||||
export default crossfilter;
|
||||
export { default } from "./crossfilter";
|
||||
|
||||
Binary file not shown.
@@ -66,8 +66,10 @@ release-stage-1 : dev-env bump clean-lite gen-package-lock
|
||||
# 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`"
|
||||
@echo "Test the install:"
|
||||
@echo " make install-release-test"
|
||||
@echo "Then upload to Pypi prod:"
|
||||
@echo " make twine-prod"
|
||||
|
||||
release-stage-final: twine-prod
|
||||
@echo "Release uploaded to pypi.org"
|
||||
@@ -76,7 +78,8 @@ release-stage-final: twine-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`"
|
||||
@echo "Test the install:"
|
||||
@echo " make install-release"
|
||||
|
||||
dev-env :
|
||||
pip install -r server/requirements-dev.txt
|
||||
@@ -120,6 +123,6 @@ install-release : uninstall
|
||||
@echo "Installed cellxgene from pypi.org"
|
||||
|
||||
uninstall :
|
||||
yes | pip uninstall cellxgene || true
|
||||
pip uninstall -y cellxgene || :
|
||||
|
||||
.PHONY : install install-dev install-release-test install-release uninstall
|
||||
|
||||
@@ -65,6 +65,11 @@ class ConfigAPI(Resource):
|
||||
"parameters": {
|
||||
"max_category_items": current_app.data.max_category_items
|
||||
},
|
||||
"library_versions": {
|
||||
"scanpy": pkg_resources.get_distribution("scanpy").version,
|
||||
"cellxgene": pkg_resources.get_distribution("cellxgene").version,
|
||||
"anndata": pkg_resources.get_distribution("cellxgene").version
|
||||
}
|
||||
}
|
||||
}
|
||||
return make_response(jsonify(config), HTTPStatus.OK)
|
||||
|
||||
@@ -143,6 +143,9 @@ class ScanpyEngine(CXGDriver):
|
||||
"using `cellxgene prepare`, please run `cellxgene prepare --help` for more "
|
||||
"information."
|
||||
)
|
||||
except MemoryError:
|
||||
raise ScanpyFileError("Error while loading file: out of memory, file is too large"
|
||||
" for memory available")
|
||||
except Exception as e:
|
||||
raise ScanpyFileError(
|
||||
f"Error while loading file: {e}, File must be in the .h5ad format, please check "
|
||||
@@ -325,10 +328,15 @@ class ScanpyEngine(CXGDriver):
|
||||
* only returns Matrix in columnar layout
|
||||
"""
|
||||
try:
|
||||
df_layout = self.data.obsm[f"X_{self.layout_method}"]
|
||||
full_embedding = self.data.obsm[f"X_{self.layout_method}"]
|
||||
if full_embedding.shape[1] > 2:
|
||||
warnings.warn(f"Warning: found {full_embedding.shape[1]} \
|
||||
components of embedding. Using the first two for layout display.")
|
||||
df_layout = full_embedding[:, :2]
|
||||
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 = (df_layout - df_layout.min()) / (df_layout.max() - df_layout.min())
|
||||
return encode_matrix_fbs(normalized_layout.astype(dtype=np.float32), col_idx=None, row_idx=None)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ from .prepare import prepare
|
||||
|
||||
|
||||
@click.group(name="cellxgene", context_settings=dict(max_content_width=85))
|
||||
@click.version_option(version="0.7.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
|
||||
@click.version_option(version="0.8.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ from server.app.util.utils import custom_format_warning
|
||||
@click.command()
|
||||
@click.argument("data", metavar="<data file>", type=click.Path(exists=True, file_okay=True, dir_okay=False))
|
||||
@click.option(
|
||||
"--layout", "-l", type=click.Choice(["umap", "tsne"]), default="umap", show_default=True, help="Method for layout."
|
||||
"--layout",
|
||||
"-l",
|
||||
type=click.Choice(["umap", "tsne", "draw_graph_fa", "draw_graph_fr", "diffmap", "phate"]),
|
||||
default="umap",
|
||||
show_default=True,
|
||||
help="Method for layout."
|
||||
)
|
||||
@click.option(
|
||||
"--diffexp",
|
||||
|
||||
@@ -7,8 +7,9 @@ Flask-Cors>=3.0.6
|
||||
Flask-RESTful>=0.3.6
|
||||
flatbuffers>=1.10.0
|
||||
matplotlib>=2.2
|
||||
numpy>=1.14.5
|
||||
numpy>=1.15.2
|
||||
pandas>=0.23.1
|
||||
scanpy>=1.3.2
|
||||
scipy>=1.1.0
|
||||
scikit-learn>=0.19.1,!=0.20.0
|
||||
tables>=3.5.1
|
||||
|
||||
@@ -54,6 +54,7 @@ class EndPoints(unittest.TestCase):
|
||||
self.assertEqual(result.status_code, HTTPStatus.OK)
|
||||
self.assertEqual(result.headers["Content-Type"], "application/json")
|
||||
result_data = result.json()
|
||||
self.assertIn("library_versions", result_data["config"])
|
||||
self.assertEqual(result_data["config"]["displayNames"]["dataset"], "pbmc3k")
|
||||
self.assertEqual(len(result_data["config"]["features"]), 4)
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
from setuptools import setup, find_packages
|
||||
import sys
|
||||
|
||||
if sys.version_info[0:2] != (3, 6):
|
||||
raise ImportError(
|
||||
"cellxgene currently only supports python 3.6. Python 3.7 is known to fail; we will look at supporting "
|
||||
"versions other than 3.6 in the future."
|
||||
"See https://github.com/chanzuckerberg/cellxgene#conda-and-virtual-environments "
|
||||
"for more help with installation."
|
||||
)
|
||||
|
||||
with open("README.md", "rb") as fh:
|
||||
long_description = fh.read().decode()
|
||||
@@ -17,7 +8,7 @@ with open("server/requirements.txt") as fh:
|
||||
|
||||
setup(
|
||||
name="cellxgene",
|
||||
version="0.7.0",
|
||||
version="0.8.0",
|
||||
packages=find_packages(),
|
||||
url="https://github.com/chanzuckerberg/cellxgene",
|
||||
license="MIT",
|
||||
@@ -40,6 +31,7 @@ setup(
|
||||
"Programming Language :: JavaScript",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
||||
],
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
set -eo pipefail
|
||||
flake8 server
|
||||
npm run --prefix client/ build
|
||||
npm run --prefix client/ unit-test
|
||||
pytest -s server/test
|
||||
Reference in New Issue
Block a user