Compare commits

...
28 Commits
Author SHA1 Message Date
Bruce Martin c6252825f3 release 0.10.0 (#794) 2019-05-29 16:55:43 -07:00
Bruce Martin 4b96b3a635 fix incompatibility of flask reload and port searching (#793)
* WIP

* add --developer; fix incompatibility of --port and --debug

* put REST tests on separate ports

* PR review
2019-05-29 16:38:57 -07:00
Colin Megill 862d8feb5e x (#792) 2019-05-29 12:23:30 -04:00
Bruce Martin 1ef77d1596 fix misconfiguration for history management (#787) 2019-05-24 21:01:11 -07:00
Bruce Martin 3dc45d6330 do not hard-wire column names in annotations (#785)
* enforce column name uniqueness for obs and var

* parameterize the column name containing obs and var user-readable names

* use the new annotation index value from schema

* update f/e unit tests

* PR review suggestions

* lint
2019-05-24 21:00:54 -07:00
Bruce Martin a8c2e408d1 update to latest anndata and remove restriction on scipy (#790) 2019-05-24 11:23:35 -07:00
Bruce Martin e941c1a496 scaling omitted from event handlers (#789)
* scaling omitted from event handlers

* fix smoke tests
2019-05-24 07:00:04 -07:00
Colin Megill a657eb3152 Logo (#782)
* logo, black

* fixes

* remove template, move header
2019-05-23 11:47:24 -04:00
Bruce Martin ef7c26e799 correctly handle selection of trunctated categories (#781) 2019-05-23 08:46:22 -07:00
Bruce Martin 49af278de7 cleanup memoiziation in graph component (#783) 2019-05-22 17:37:18 -07:00
Bruce Martin 2357d0c1b8 layout change UI (#776)
* add layout to schema

* add layout choice action and reducer

* multi layout UI

* update unit tests

* add missing file

* update test schema

* fix duplicate test id

* fix tabs

* PR lint

* fix pytest
2019-05-22 13:21:33 -07:00
Charlotte Weaver 63af79d3f8 Add developer guidelines (#769)
* Add developer guidelines

* minor formatting

* PR clarifications/lint

* more pr fixes

* link fix

* below->above

* pr suggestions
2019-05-21 13:57:34 -07:00
Bruce Martin fcc05f6a00 coordinate system fixes for embedded graph (#768)
* change pan speed to 1 per issue #722

* correct handle scaling of graph when aspect ratio less than one

* add package lock

* add invert to our scale functions

* correctly transform to/from gl coordinates

* remove unused import

* fix naming of import

* update smoke tests
2019-05-20 14:22:41 -07:00
Bruce Martin de3407d875 change scripts to support windows (#775) 2019-05-20 11:42:57 -07:00
Charlotte Weaver 2d4e827bea wait for element before getting text/html (#777) 2019-05-20 11:35:53 -07:00
Bruce Martin 1fa4838863 npm (js) package dependency updates (#765)
* JS package dependency updates

* additional package updates

* more package version updates

* more js package updates

* more JS dependency updates
2019-05-20 10:11:49 -07:00
Charlotte Weaver ab4c74a321 remove psutil (#773) 2019-05-18 10:53:45 -07:00
Charlotte Weaver 82d65addec always run smoke tests (#772) 2019-05-18 10:50:17 -07:00
Charlotte Weaver e2ad28a510 exclude recent scipy versions (#770) 2019-05-17 15:13:22 -07:00
Bruce Martin efa1709158 add multi-layout support to back-end (#766)
* add multi-layout support to back-end

* remove obsolete code

* temporary code to apply heuristic choice of default layout

* fix tests

* update python tests

* more py lint

* PR review changes

* more PR lint

* PR lint
2019-05-16 14:49:22 -07:00
Charlotte WeaverandTony Tung d6040f687a port retry (#761)
* WIP

* import find_available_port method

* move method to utils

so I can add to eventually add to gui

* add fixed-port flag to tests

* Update server/utils/utils.py

Co-Authored-By: Tony Tung <tonytung@merly.org>

* pr review suggestions

* pr review suggestions

* fix outdated package.json

* update error message

* simplify find_available_port function

* Auto scan for ports unless port is specified.

* fix tests

* fix comment for find_available_port

* lint error

* differentiate port error from generic os error

* add errno to OSerror

* pr review fixes

* raise e -> raise

* oserror -> socket error
2019-05-14 14:04:13 -07:00
Bruce Martin b9a1e30652 large file size guardrails (#763)
* large file guardrails

* fix lint

* PR review

* remove unused import

* use standard slice for CSR

* revert change
2019-05-13 18:13:16 -07:00
Bruce Martin 7adac5d004 create occupancy stacks for all category values, not just top N values (#764) 2019-05-13 11:22:46 -07:00
Charlotte Weaver 2354731083 install from dist instead of build on travis (#760) 2019-05-09 15:32:06 -07:00
Charlotte Weaver d522cc8f91 Add clipping test to smoke tests (#757)
* Add clipping test to smoke tests

* devtools on in debug
2019-05-09 15:31:55 -07:00
Charlotte Weaver 86eb01eb2c improve release process (#752)
* Add --no-cache-dir to make release-install target

Prevents installing from cache so you get the freshest release

* Testing releases is not optional

* Updated release documentation
2019-05-08 09:36:00 -07:00
Charlotte Weaver 8a94b1e086 fix #754 (#755) 2019-05-07 12:43:20 -07:00
Bruce Martin 846b8d15bd lodash cleanup (#747)
* add own range() function

* lodash cleanup

* remove redundant fill range implementations

* remove use of _.get

* sync test babel config with build

* update tests to match new range implementation
2019-05-06 20:28:32 -04:00
59 changed files with 6304 additions and 3971 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.9.1 current_version = 0.10.0
[bumpversion:file:setup.py] [bumpversion:file:setup.py]
search = version="{current_version}" search = version="{current_version}"
+2 -3
View File
@@ -9,8 +9,8 @@ cache:
install: install:
- set -eo pipefail - set -eo pipefail
- pip install flake8 - pip install flake8
- make build - make pydist
- make install - make install-dist
- pip install -r server/requirements-dev.txt - pip install -r server/requirements-dev.txt
jobs: jobs:
@@ -27,6 +27,5 @@ jobs:
script: docker build . script: docker build .
- name: "Smoke Tests" - name: "Smoke Tests"
python: "3.6" python: "3.6"
if: branch = master AND type = cron
script: script:
- npm run --prefix client/ smoke-test - npm run --prefix client/ smoke-test
+14 -5
View File
@@ -1,6 +1,6 @@
export const datasets = { export const datasets = {
pbmc3k: { pbmc3k: {
title: "cellxgene: pbmc3k", title: "pbmc3k",
dataframe: { dataframe: {
nObs: "2638", nObs: "2638",
nVar: "1838", nVar: "1838",
@@ -26,8 +26,8 @@ export const datasets = {
cellsets: { cellsets: {
lasso: [ lasso: [
{ {
"coordinates-as-percent": { x1: 0.25, y1: 0.25, x2: 0.35, y2: 0.35 }, "coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 },
count: "26" count: "104"
} }
], ],
categorical: [ categorical: [
@@ -91,8 +91,8 @@ export const datasets = {
} }
}, },
lasso: { lasso: {
"coordinates-as-percent": { x1: 0.45, y1: 0.45, x2: 0.5, y2: 0.5 }, "coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.5, y2: 0.1 },
count: "67" count: "76"
} }
}, },
scatter: { scatter: {
@@ -108,6 +108,15 @@ export const datasets = {
count: "24" count: "24"
} }
} }
},
clip: {
min: "30",
max: "70",
metadata: "n_genes",
gene: "S100A8",
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
count: "392",
"gene-cell-count": "421"
} }
} }
}; };
+29 -1
View File
@@ -182,7 +182,6 @@ describe("diffexp", async () => {
); );
}); });
}); });
//
describe("subset/reset", async () => { describe("subset/reset", async () => {
test("subset - cell count matches", async () => { test("subset - cell count matches", async () => {
@@ -271,6 +270,35 @@ describe("scatter plot", async () => {
}); });
}); });
describe("clipping", async () => {
test("clip continuous", async () => {
await cxgActions.clip(data.clip.min, data.clip.max);
const histId = `histogram-${data.clip.metadata}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates(
histId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.clip.count);
});
test("clip gene", async () => {
await utils.typeInto("gene-search", data.clip.gene);
await page.keyboard.press("Enter");
await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`);
await cxgActions.clip(data.clip.min, data.clip.max);
const histId = `histogram-${data.clip.gene}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates(
histId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.clip["gene-cell-count"]);
});
});
// interact with UI elements just that they do not break // interact with UI elements just that they do not break
describe("ui elements don't error", async () => { describe("ui elements don't error", async () => {
test("color by", async () => { test("color by", async () => {
+27 -2
View File
@@ -16,10 +16,25 @@ export const puppeteerUtils = puppeteerPage => ({
async typeInto(testid, text) { async typeInto(testid, text) {
// only works for text without special characters // only works for text without special characters
await this.waitByID(testid); await this.waitByID(testid);
const selector = `[data-testid='${testid}']`;
// type ahead can be annoying if you don't pause before you type // type ahead can be annoying if you don't pause before you type
await puppeteerPage.click(`[data-testid='${testid}']`); await puppeteerPage.click(selector);
await puppeteerPage.waitFor(200); await puppeteerPage.waitFor(200);
await puppeteerPage.type(`[data-testid='${testid}']`, text); await puppeteerPage.type(selector, text);
},
async clearInputAndTypeInto(testid, text) {
await this.waitByID(testid);
const selector = `[data-testid='${testid}']`;
// only works for text without special characters
// type ahead can be annoying if you don't pause before you type
await puppeteerPage.click(selector);
await puppeteerPage.waitFor(200);
// select all
await puppeteerPage.click(selector, {clickCount: 3})
await puppeteerPage.keyboard.type("Backspace")
await puppeteerPage.type(selector, text);
}, },
async clickOn(testid) { async clickOn(testid) {
@@ -29,11 +44,13 @@ export const puppeteerUtils = puppeteerPage => ({
}, },
async getOneElementInnerHTML(selector) { async getOneElementInnerHTML(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerHTML); let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
return text; return text;
}, },
async getOneElementInnerText(selector) { async getOneElementInnerText(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerText); let text = await puppeteerPage.$eval(selector, el => el.innerText);
return text; return text;
} }
@@ -161,5 +178,13 @@ export const cellxgeneActions = puppeteerPage => ({
await puppeteerUtils(puppeteerPage).clickOn("reset"); await puppeteerUtils(puppeteerPage).clickOn("reset");
// loading state never actually happens, reset is too fast // loading state never actually happens, reset is too fast
await page.waitFor(200); await page.waitFor(200);
},
async clip(min = 0, max = 100) {
await puppeteerUtils(puppeteerPage).clickOn("visualization-settings");
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto("clip-min-input", min);
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto("clip-max-input", max);
await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
} }
}); });
+42
View File
@@ -0,0 +1,42 @@
import { range, rangeFill } from "../../src/util/range";
describe("range", () => {
test("no defaults", () => {
expect(range(0, 3, 1)).toMatchObject([0, 1, 2]);
});
test("range(stop)", () => {
expect(range(3)).toMatchObject([0, 1, 2]);
expect(range(0)).toMatchObject([]);
expect(range(1)).toMatchObject([0]);
});
test("range(start,stop)", () => {
expect(range(0, 0)).toMatchObject([]);
expect(range(0, 2)).toMatchObject([0, 1]);
expect(range(4, 8)).toMatchObject([4, 5, 6, 7]);
});
test("range(start, stop, step", () => {
expect(range(4, 0, -1)).toMatchObject([4, 3, 2, 1]);
expect(range(0, 4, 2)).toMatchObject([0, 2]);
});
});
describe("rangefill", () => {
test("rangeFill(arr)", () => {
expect(rangeFill(new Int32Array(3))).toMatchObject(
new Int32Array([0, 1, 2])
);
});
test("rangeFill(arr, start)", () => {
expect(rangeFill(new Int32Array(2), 1)).toMatchObject(
new Int32Array([1, 2])
);
});
test("rangeFill(arr, start, step)", () => {
expect(rangeFill(new Int32Array(3), 2, -1)).toMatchObject(
new Int32Array([2, 1, 0])
);
});
});
@@ -34,28 +34,38 @@ const aSchemaResponse = {
type: "float32" type: "float32"
}, },
annotations: { annotations: {
obs: [ obs: {
{ name: "name", type: "string" }, index: "name",
{ name: "field1", type: "int32" }, columns: [
{ name: "field2", type: "float32" }, { name: "name", type: "string" },
{ name: "field3", type: "boolean" }, { name: "field1", type: "int32" },
{ { name: "field2", type: "float32" },
name: "field4", { name: "field3", type: "boolean" },
type: "categorical", {
categories: field4Categories name: "field4",
} type: "categorical",
], categories: field4Categories
var: [ }
{ name: "name", type: "string" }, ]
{ name: "fieldA", type: "int32" }, },
{ name: "fieldB", type: "float32" }, var: {
{ name: "fieldC", type: "boolean" }, index: "name",
{ columns: [
name: "fieldD", { name: "name", type: "string" },
type: "categorical", { name: "fieldA", type: "int32" },
categories: fieldDCategories { name: "fieldB", type: "float32" },
} { name: "fieldC", type: "boolean" },
] {
name: "fieldD",
type: "categorical",
categories: fieldDCategories
}
]
}
},
layout: {
obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
var: []
} }
} }
}; };
@@ -162,29 +172,7 @@ const aLayoutFBSResponse = (() => {
new Float32Array(nObs).fill(Math.random()), new Float32Array(nObs).fill(Math.random()),
new Float32Array(nObs).fill(Math.random()) new Float32Array(nObs).fill(Math.random())
]; ];
const builder = new flatbuffers.Builder(1024); return encodeMatrix(coords, ["umap_0", "umap_1"]);
const cols = _.map(coords, carr => {
const cdv = NetEncoding.Float32Array.createDataVector(builder, carr);
NetEncoding.Float32Array.startFloat32Array(builder);
NetEncoding.Float32Array.addData(builder, cdv);
const floatArr = NetEncoding.Float32Array.endFloat32Array(builder);
NetEncoding.Column.startColumn(builder);
NetEncoding.Column.addUType(builder, NetEncoding.TypedArray.Float32Array);
NetEncoding.Column.addU(builder, floatArr);
return NetEncoding.Column.endColumn(builder);
});
const columns = NetEncoding.Matrix.createColumnsVector(builder, cols);
NetEncoding.Matrix.startMatrix(builder);
NetEncoding.Matrix.addNRows(builder, nObs);
NetEncoding.Matrix.addNCols(builder, coords.length);
NetEncoding.Matrix.addColumns(builder, columns);
const matrix = NetEncoding.Matrix.endMatrix(builder);
builder.finish(matrix);
return builder.asUint8Array();
})(); })();
const aDataObsResponse = { const aDataObsResponse = {
@@ -53,13 +53,15 @@ describe("createUniverseFromResponse", () => {
expect(universe.obsAnnotations.dims).toEqual([ expect(universe.obsAnnotations.dims).toEqual([
nObs, nObs,
REST.schema.schema.annotations.obs.length REST.schema.schema.annotations.obs.columns.length
]); ]);
expect(universe.obsLayout.dims).toEqual([nObs, 2]); expect(universe.obsLayout.dims).toEqual([nObs, 2]);
expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]); expect(universe.obsLayout.colIndex.keys()).toEqual(
universe.schema.layout.obs[0].dims
);
expect(universe.varAnnotations.dims).toEqual([ expect(universe.varAnnotations.dims).toEqual([
nVar, nVar,
REST.schema.schema.annotations.var.length REST.schema.schema.annotations.var.columns.length
]); ]);
expect(universe.varData.isEmpty()).toBeTruthy(); expect(universe.varData.isEmpty()).toBeTruthy();
}); });
@@ -29,7 +29,8 @@ const defaultBigBang = () => {
/* create crossfilter */ /* create crossfilter */
const crossfilter = World.createObsDimensions( const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations), new Crossfilter(world.obsAnnotations),
world world,
REST.schema.schema.layout.obs[0].dims
); );
return { return {
@@ -138,7 +139,9 @@ describe("createWorldFromCurrentSelection", () => {
expect(world.obsLayout.rowIndex.keys()).toEqual( expect(world.obsLayout.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices) new Int32Array(matchingIndices)
); );
expect(world.obsLayout.colIndex.keys()).toEqual(["X", "Y"]); expect(world.obsLayout.colIndex.keys()).toEqual(
world.schema.layout.obs[0].dims
);
}); });
}); });
@@ -152,14 +155,18 @@ describe("createObsDimensionMap", () => {
const { crossfilter } = defaultBigBang(); const { crossfilter } = defaultBigBang();
const annotationNames = _.map( const annotationNames = _.map(
REST.schema.schema.annotations.obs, REST.schema.schema.annotations.obs.columns,
c => c.name c => c.name
); );
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name"); const obsIndexColName = REST.schema.schema.annotations.obs.index;
const schemaByObsName = _.keyBy(
REST.schema.schema.annotations.obs.columns,
"name"
);
expect(crossfilter).toBeDefined(); expect(crossfilter).toBeDefined();
annotationNames.forEach(name => { annotationNames.forEach(name => {
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)]; const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
if (name === "name") { if (name === obsIndexColName) {
expect(dim).toBeUndefined(); expect(dim).toBeUndefined();
} else { } else {
const { type } = schemaByObsName[name]; const { type } = schemaByObsName[name];
@@ -1,8 +1,8 @@
import { import {
fillRange,
sliceByIndex, sliceByIndex,
makeSortIndex makeSortIndex
} from "../../../src/util/typedCrossfilter/util"; } from "../../../src/util/typedCrossfilter/util";
import { rangeFill as fillRange } from "../../../src/util/range";
describe("fillRange", () => { describe("fillRange", () => {
test("Array", () => { test("Array", () => {
+1 -1
View File
@@ -7,8 +7,8 @@ module.exports = {
], ],
plugins: [ plugins: [
"@babel/plugin-proposal-function-bind", "@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }], ["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
"@babel/plugin-proposal-export-namespace-from", "@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-optional-chaining", "@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator" "@babel/plugin-proposal-nullish-coalescing-operator"
+1 -1
View File
@@ -6,8 +6,8 @@ module.exports = {
], ],
plugins: [ plugins: [
"@babel/plugin-proposal-function-bind", "@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }], ["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
"@babel/plugin-proposal-export-namespace-from", "@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements", "@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime", "@babel/plugin-transform-runtime",
+5067 -3433
View File
File diff suppressed because it is too large Load Diff
+55 -50
View File
@@ -1,6 +1,6 @@
{ {
"name": "cellxgene", "name": "cellxgene",
"version": "0.9.1", "version": "0.10.0",
"license": "MIT", "license": "MIT",
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.", "description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
"repository": "https://github.com/chanzuckerberg/cellxgene", "repository": "https://github.com/chanzuckerberg/cellxgene",
@@ -9,13 +9,13 @@
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", "build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"clean": "rimraf build", "clean": "rimraf build",
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js", "dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
"e2e": "jest --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js", "e2e": "node node_modules/jest/bin/jest.js --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
"lint": "eslint src", "lint": "eslint src",
"smoke-test": "start-server-and-test start-server-for-test :5000 e2e", "smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
"start": "node server/development.js", "start": "node server/development.js",
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad", "start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad",
"test": "jest", "test": "node node_modules/jest/bin/jest.js",
"unit-test": "jest --testPathIgnorePatterns e2e" "unit-test": "node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e"
}, },
"engineStrict": true, "engineStrict": true,
"engines": { "engines": {
@@ -31,8 +31,8 @@
"eslint-scope": "3.7.1" "eslint-scope": "3.7.1"
}, },
"dependencies": { "dependencies": {
"@blueprintjs/core": "^3.15.0", "@blueprintjs/core": "^3.15.1",
"@blueprintjs/icons": "^3.3.0", "@blueprintjs/icons": "^3.8.0",
"@blueprintjs/select": "^3.8.0", "@blueprintjs/select": "^3.8.0",
"canvas-fit": "^1.5.0", "canvas-fit": "^1.5.0",
"d3": "^4.10.0", "d3": "^4.10.0",
@@ -41,79 +41,77 @@
"font-color-contrast": "^1.0.3", "font-color-contrast": "^1.0.3",
"fuzzysort": "^1.1.4", "fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4", "gl-mat4": "^1.1.4",
"gl-matrix": "^2.7.1", "gl-vec3": "^1.1.3",
"gl-matrix": "^3.0.0",
"is-number": "^7.0.0", "is-number": "^7.0.0",
"key-pressed": "0.0.1", "key-pressed": "0.0.1",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"memoize-one": "^4.0.0", "memoize-one": "^5.0.4",
"mouse-position": "^2.0.1", "mouse-position": "^2.0.1",
"mouse-pressed": "^1.0.0", "mouse-pressed": "^1.0.0",
"normalize.css": "^8.0.0",
"orbit-camera": "^1.0.0", "orbit-camera": "^1.0.0",
"query-string": "^6.1.0", "query-string": "^6.5.0",
"react": "^16.6.0", "react": "^16.8.6",
"react-autocomplete": "^1.7.2", "react-autocomplete": "^1.7.2",
"react-dom": "^16.6.0", "react-dom": "^16.8.6",
"react-helmet": "^5.2.0", "react-helmet": "^5.2.1",
"react-icons": "^3.2.2", "react-icons": "^3.7.0",
"react-redux": "^5.1.0", "react-redux": "^7.0.3",
"redux": "^4.0.1", "redux": "^4.0.1",
"redux-devtools-extension": "^2.13.5",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"regl": "^1.3.9", "regl": "^1.3.11",
"scroll-speed": "^1.0.0", "scroll-speed": "^1.0.0",
"urijs": "^1.19.0" "urijs": "^1.19.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.1.5", "@babel/core": "^7.4.4",
"@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-proposal-decorators": "^7.0.0", "@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0", "@babel/plugin-proposal-export-namespace-from": "^7.2.0",
"@babel/plugin-proposal-function-bind": "^7.0.0", "@babel/plugin-proposal-function-bind": "^7.2.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.2.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4",
"@babel/plugin-proposal-optional-chaining": "^7.2.0", "@babel/plugin-proposal-optional-chaining": "^7.2.0",
"@babel/plugin-transform-react-constant-elements": "^7.0.0", "@babel/plugin-transform-react-constant-elements": "^7.2.0",
"@babel/plugin-transform-runtime": "^7.1.0", "@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.1.5", "@babel/preset-env": "^7.4.4",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@babel/register": "^7.0.0", "@babel/register": "^7.4.4",
"@babel/runtime": "^7.1.5", "@babel/runtime": "^7.4.4",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0", "babel-jest": "^24.8.0",
"babel-loader": "^8.0.0", "babel-loader": "^8.0.6",
"babel-preset-modern-browsers": "^12.0.0", "babel-preset-modern-browsers": "^14.0.0",
"chalk": "^2.4.2", "chalk": "^2.4.2",
"connect-history-api-fallback": "^1.6.0", "connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^4.6.0", "copy-webpack-plugin": "^5.0.3",
"css-loader": "^1.0.1", "css-loader": "^2.1.1",
"eslint": "^5.13.0", "eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.0", "eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^4.0.0", "eslint-config-prettier": "^4.2.0",
"eslint-loader": "^2.1.2", "eslint-loader": "^2.1.2",
"eslint-plugin-filenames": "^1.3.2", "eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.16.0", "eslint-plugin-import": "^2.17.2",
"eslint-plugin-jest": "^22.2.2", "eslint-plugin-jest": "^22.5.1",
"eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-react": "^7.12.4", "eslint-plugin-react": "^7.13.0",
"express": "^4.14.0", "express": "^4.14.0",
"file-loader": "^2.0.0", "file-loader": "^3.0.1",
"html-webpack-inline-source-plugin": "0.0.10", "html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"jest": "^24.1.0", "jest": "^24.8.0",
"jest-puppeteer": "^4.1.0", "jest-puppeteer": "^4.1.1",
"json-loader": "^0.5.4", "json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.4.1", "mini-css-extract-plugin": "^0.6.0",
"puppeteer": "^1.12.1", "puppeteer": "^1.16.0",
"rimraf": "^2.6.3", "rimraf": "^2.6.3",
"serve-favicon": "^2.3.0", "serve-favicon": "^2.3.0",
"start-server-and-test": "^1.7.11", "start-server-and-test": "^1.9.0",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5", "sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^1.1.0", "url-loader": "^1.1.0",
"webpack": "^4.25.1", "webpack": "^4.31.0",
"webpack-cli": "^3.1.0", "webpack-cli": "^3.3.2",
"webpack-dev-middleware": "^3.1.3" "webpack-dev-middleware": "^3.6.2"
}, },
"jest": { "jest": {
"testMatch": [ "testMatch": [
@@ -133,16 +131,23 @@
], ],
"plugins": [ "plugins": [
"@babel/plugin-proposal-function-bind", "@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
[ [
"@babel/plugin-proposal-decorators", "@babel/plugin-proposal-decorators",
{ {
"legacy": true "legacy": true
} }
], ],
[
"@babel/plugin-proposal-class-properties",
{
"loose": true
}
],
"@babel/plugin-proposal-export-namespace-from", "@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements", "@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"
] ]
} }
} }
+31 -20
View File
@@ -21,24 +21,32 @@ const doInitialDataLoad = () =>
dispatch({ type: "initial data load start" }); dispatch({ type: "initial data load start" });
try { try {
const requestJson = _(["config", "schema"]) /*
Step 1 - config & schema, all JSON
*/
const requestJson = ["config", "schema"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`) .map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doJsonRequest(url)) .map(url => doJsonRequest(url));
.value(); const stepOneResults = await Promise.all(requestJson);
const requestBinary = _([
"annotations/obs",
"annotations/var?annotation-name=name",
"layout/obs"
])
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doBinaryRequest(url))
.value();
const results = await Promise.all(_.concat(requestJson, requestBinary));
/* set config defaults */ /* set config defaults */
const config = { ...globals.configDefaults, ...results[0].config }; const config = { ...globals.configDefaults, ...stepOneResults[0].config };
const [, schema, obsAnno, varAnno, obsLayout] = [...results]; const schema = stepOneResults[1];
/*
Step 2 - dataframes, all binary. NOTE: uses results of step 1.
*/
/* only load names for var annotations, if possible*/
const varIndexName = schema?.schema?.annotations?.var?.index;
const varAnnotationsQuery = varIndexName
? `?annotation-name=${varIndexName}`
: "";
const varAnnotationsURL = `annotations/var${varAnnotationsQuery}`;
const requestBinary = ["annotations/obs", varAnnotationsURL, "layout/obs"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doBinaryRequest(url));
const stepTwoResults = await Promise.all(requestBinary);
const [obsAnno, varAnno, obsLayout] = [...stepTwoResults];
const universe = Universe.createUniverseFromResponse( const universe = Universe.createUniverseFromResponse(
config, config,
schema, schema,
@@ -91,6 +99,10 @@ needs expression data.
Transparently utilizes cached data if it is already present. Transparently utilizes cached data if it is already present.
*/ */
async function _doRequestExpressionData(dispatch, getState, genes) { async function _doRequestExpressionData(dispatch, getState, genes) {
const state = getState();
const { universe } = state;
const varIndexName = universe.schema.annotations.var.index;
/* helper for this function only */ /* helper for this function only */
const fetchData = async geneNames => { const fetchData = async geneNames => {
const res = await fetch( const res = await fetch(
@@ -100,7 +112,7 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
body: JSON.stringify({ body: JSON.stringify({
filter: { filter: {
var: { var: {
annotation_value: [{ name: "name", values: geneNames }] annotation_value: [{ name: varIndexName, values: geneNames }]
} }
} }
}), }),
@@ -123,8 +135,6 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
return Universe.convertDataFBStoObject(universe, data); return Universe.convertDataFBStoObject(universe, data);
}; };
const state = getState();
const { universe } = state;
/* preload data already in cache */ /* preload data already in cache */
let expressionData = _.transform( let expressionData = _.transform(
genes, genes,
@@ -241,6 +251,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
*/ */
const state = getState(); const state = getState();
const { universe } = state; const { universe } = state;
const varIndexName = universe.schema.annotations.var.index;
// Legal values are null, Array or TypedArray. Null is initial state. // Legal values are null, Array or TypedArray. Null is initial state.
if (!set1) set1 = []; if (!set1) set1 = [];
@@ -277,7 +288,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
const data = await res.json(); const data = await res.json();
// result is [ [varIdx, ...], ... ] // result is [ [varIdx, ...], ... ]
const topNGenes = _.map(data, r => const topNGenes = _.map(data, r =>
universe.varAnnotations.at(r[0], "name") universe.varAnnotations.at(r[0], varIndexName)
); );
/* /*
@@ -27,10 +27,10 @@ class Category extends React.Component {
const cat = categoricalSelection[metadataField]; const cat = categoricalSelection[metadataField];
const categoryCount = { const categoryCount = {
// total number of categories in this dimension // total number of categories in this dimension
totalCatCount: cat.numCategories, totalCatCount: cat.numCategoryValues,
// number of selected options in this category // number of selected options in this category
selectedCatCount: _.reduce( selectedCatCount: _.reduce(
cat.categorySelected, cat.categoryValueSelected,
(res, cond) => (cond ? res + 1 : res), (res, cond) => (cond ? res + 1 : res),
0 0
) )
@@ -91,7 +91,7 @@ class Category extends React.Component {
const { categoricalSelection, metadataField } = this.props; const { categoricalSelection, metadataField } = this.props;
const cat = categoricalSelection[metadataField]; const cat = categoricalSelection[metadataField];
const optTuples = sortedCategoryValues([...cat.categoryIndices]); const optTuples = sortedCategoryValues([...cat.categoryValueIndices]);
return _.map(optTuples, (tuple, i) => ( return _.map(optTuples, (tuple, i) => (
<Value <Value
optTuples={optTuples} optTuples={optTuples}
+5 -13
View File
@@ -1,25 +1,16 @@
// jshint esversion: 6 // jshint esversion: 6
import React from "react"; import React from "react";
import _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
@connect() @connect()
class Occupancy extends React.Component { class Occupancy extends React.Component {
render() { render() {
const { const { occupancy, colorScale, colorAccessor, schema, world } = this.props;
occupancy,
colorScale,
categoricalSelection,
colorAccessor,
schema
} = this.props;
const width = 100; const width = 100;
const height = 11; const height = 11;
const categories = _.filter(schema.annotations.obs, { const categories = schema.annotations.obsByName[colorAccessor]?.categories;
name: colorAccessor
})[0].categories;
const x = d3 const x = d3
.scaleLinear() .scaleLinear()
@@ -28,8 +19,9 @@ class Occupancy extends React.Component {
.range([0, width]); .range([0, width]);
let currentOffset = 0; let currentOffset = 0;
const dfColumn = world.obsAnnotations.col(colorAccessor);
const stacks = categoricalSelection[colorAccessor].categoryValues.map(d => { const categoryValues = dfColumn.summarize().categories;
const stacks = categoryValues.map(d => {
const o = occupancy.get(d); const o = occupancy.get(d);
const scaledValue = x(o); const scaledValue = x(o);
+1 -2
View File
@@ -5,7 +5,6 @@
// return sorted index // return sorted index
import isNumber from "is-number"; import isNumber from "is-number";
import _ from "lodash";
const sortedCategoryValues = values => { const sortedCategoryValues = values => {
/* this sort could be memoized for perf */ /* this sort could be memoized for perf */
@@ -13,7 +12,7 @@ const sortedCategoryValues = values => {
const strings = []; const strings = [];
const ints = []; const ints = [];
_.forEach(values, v => { values.forEach(v => {
if (isNumber(v[0])) { if (isNumber(v[0])) {
ints.push(v); ints.push(v);
} else { } else {
+4 -7
View File
@@ -1,7 +1,6 @@
// jshint esversion: 6 // jshint esversion: 6
import { connect } from "react-redux"; import { connect } from "react-redux";
import React from "react"; import React from "react";
import _ from "lodash";
import Occupancy from "./occupancy"; import Occupancy from "./occupancy";
import { countCategoryValues2D } from "../../util/stateManager/worldUtil"; import { countCategoryValues2D } from "../../util/stateManager/worldUtil";
import * as globals from "../../globals"; import * as globals from "../../globals";
@@ -10,7 +9,7 @@ import * as globals from "../../globals";
categoricalSelection: state.categoricalSelection, categoricalSelection: state.categoricalSelection,
colorScale: state.colors.scale, colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor, colorAccessor: state.colors.colorAccessor,
schema: _.get(state.world, "schema", null), schema: state.world?.schema,
world: state.world world: state.world
})) }))
class CategoryValue extends React.Component { class CategoryValue extends React.Component {
@@ -47,8 +46,8 @@ class CategoryValue extends React.Component {
if (!categoricalSelection) return null; if (!categoricalSelection) return null;
const category = categoricalSelection[metadataField]; const category = categoricalSelection[metadataField];
const selected = category.categorySelected[categoryIndex]; const selected = category.categoryValueSelected[categoryIndex];
const count = category.categoryCounts[categoryIndex]; const count = category.categoryValueCounts[categoryIndex];
const value = category.categoryValues[categoryIndex]; const value = category.categoryValues[categoryIndex];
const displayString = String( const displayString = String(
category.categoryValues[categoryIndex] category.categoryValues[categoryIndex]
@@ -60,9 +59,7 @@ class CategoryValue extends React.Component {
let occupancy = null; let occupancy = null;
if (isColorBy && schema) { if (isColorBy && schema) {
categories = _.filter(schema.annotations.obs, { categories = schema.annotations.obsByName[colorAccessor]?.categories;
name: colorAccessor
})[0].categories;
} }
if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) { if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) {
@@ -9,10 +9,10 @@ import * as globals from "../../globals";
import HistogramBrush from "../brushableHistogram"; import HistogramBrush from "../brushableHistogram";
@connect(state => ({ @connect(state => ({
obsAnnotations: _.get(state.world, "obsAnnotations", null), obsAnnotations: state.world?.obsAnnotations,
colorAccessor: state.colors.colorAccessor, colorAccessor: state.colors.colorAccessor,
colorScale: state.colors.scale, colorScale: state.colors.scale,
schema: _.get(state.world, "schema", null) schema: state.world?.schema
})) }))
class Continuous extends React.Component { class Continuous extends React.Component {
constructor(props) { constructor(props) {
@@ -66,7 +66,8 @@ class Continuous extends React.Component {
? _.map(obsAnnotations.colIndex.keys(), key => { ? _.map(obsAnnotations.colIndex.keys(), key => {
const isColorField = const isColorField =
key.includes("color") || key.includes("Color"); key.includes("color") || key.includes("Color");
if (key === "name" || isColorField) return null; if (key === schema.annotations.obs.index || isColorField)
return null;
const summary = obsAnnotations.col(key).summarize(); const summary = obsAnnotations.col(key).summarize();
const nonFiniteExtent = const nonFiniteExtent =
+18
View File
@@ -0,0 +1,18 @@
import React from "react";
import * as globals from "../../globals";
const Logo = props => {
const { size } = props;
return (
<svg width={size} height={size} viewBox={`0 0 48 48`} fill="none">
<rect width="48" height="48" fill="white" />
<rect width="48" height="48" fill={globals.logoColor} />
<rect x="19" y="19" width="22" height="22" fill="white" />
<rect x="24" y="24" width="12" height="12" fill={globals.logoColor} />
<rect x="7" y="19" width="7" height="22" fill="white" />
<rect x="19" y="7" width="22" height="7" fill="white" />
</svg>
);
};
export default Logo;
+14 -7
View File
@@ -57,7 +57,7 @@ const filterGenes = (query, genes) =>
@connect(state => { @connect(state => {
return { return {
obsAnnotations: _.get(state.world, "obsAnnotations", null), obsAnnotations: state.world?.obsAnnotations,
userDefinedGenes: state.controls.userDefinedGenes, userDefinedGenes: state.controls.userDefinedGenes,
userDefinedGenesLoading: state.controls.userDefinedGenesLoading, userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
world: state.world, world: state.world,
@@ -85,7 +85,8 @@ class GeneExpression extends React.Component {
*/ */
const { world } = this.props; const { world } = this.props;
const { varAnnotations } = world; const { varAnnotations } = world;
const geneNames = varAnnotations.col("name").asArray(); const varIndexName = world.schema.annotations.var.index;
const geneNames = varAnnotations.col(varIndexName).asArray();
if (geneNames.length > 0) { if (geneNames.length > 0) {
const placeholder = []; const placeholder = [];
let len = geneNames.length; let len = geneNames.length;
@@ -107,6 +108,7 @@ class GeneExpression extends React.Component {
handleClick(g) { handleClick(g) {
const { world, dispatch, userDefinedGenes } = this.props; const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
const gene = g.target; const gene = g.target;
if (userDefinedGenes.indexOf(gene) !== -1) { if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists"); postUserErrorToast("That gene already exists");
@@ -114,7 +116,9 @@ class GeneExpression extends React.Component {
postUserErrorToast( postUserErrorToast(
"That's too many genes, you can have at most 15 user defined genes" "That's too many genes, you can have at most 15 user defined genes"
); );
} else if (world.varAnnotations.col("name").indexOf(gene) === undefined) { } else if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
postUserErrorToast("That doesn't appear to be a valid gene name."); postUserErrorToast("That doesn't appear to be a valid gene name.");
} else { } else {
dispatch({ type: "single user defined gene start" }); dispatch({ type: "single user defined gene start" });
@@ -127,6 +131,7 @@ class GeneExpression extends React.Component {
handleBulkAddClick() { handleBulkAddClick() {
const { world, dispatch, userDefinedGenes } = this.props; const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
const { bulkAdd } = this.state; const { bulkAdd } = this.state;
/* /*
@@ -145,7 +150,9 @@ class GeneExpression extends React.Component {
if (userDefinedGenes.indexOf(gene) !== -1) { if (userDefinedGenes.indexOf(gene) !== -1) {
return keepAroundErrorToast("That gene already exists"); return keepAroundErrorToast("That gene already exists");
} }
if (world.varAnnotations.col("name").indexOf(gene) === undefined) { if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
return keepAroundErrorToast( return keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.` `${gene} doesn't appear to be a valid gene name.`
); );
@@ -168,7 +175,7 @@ class GeneExpression extends React.Component {
userDefinedGenesLoading, userDefinedGenesLoading,
differential differential
} = this.props; } = this.props;
const varIndexName = world?.schema?.annotations?.var?.index;
const { tab, bulkAdd } = this.state; const { tab, bulkAdd } = this.state;
return ( return (
@@ -243,7 +250,7 @@ class GeneExpression extends React.Component {
itemRenderer={renderGene.bind(this)} itemRenderer={renderGene.bind(this)}
items={ items={
world && world.varAnnotations world && world.varAnnotations
? world.varAnnotations.col("name").asArray() ? world.varAnnotations.col(varIndexName).asArray()
: ["No genes"] : ["No genes"]
} }
popoverProps={{ minimal: true }} popoverProps={{ minimal: true }}
@@ -322,7 +329,7 @@ class GeneExpression extends React.Component {
<ExpressionButtons /> <ExpressionButtons />
{differential.diffExp {differential.diffExp
? _.map(differential.diffExp, (value, index) => { ? _.map(differential.diffExp, (value, index) => {
const name = world.varAnnotations.at(value[0], "name"); const name = world.varAnnotations.at(value[0], varIndexName);
const values = world.varData.col(name); const values = world.varData.col(name);
if (!values) { if (!values) {
return null; return null;
+15 -1
View File
@@ -1,5 +1,6 @@
// jshint esversion: 6 // jshint esversion: 6
const mat4 = require("gl-mat4"); const mat4 = require("gl-mat4");
const vec3 = require("gl-vec3");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js // opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
@@ -38,7 +39,20 @@ export default function(regl) {
uniforms: { uniforms: {
distance: regl.prop("distance"), distance: regl.prop("distance"),
view: regl.prop("view"), view: regl.prop("view"),
projection: ({viewportWidth, viewportHeight}) => mat4.perspective([], Math.PI / 2, viewportWidth / viewportHeight, 0.01, 1000) projection: ({ viewportWidth, viewportHeight }) => {
const aspectRatio = viewportWidth / viewportHeight;
let m = mat4.perspective(
[],
Math.PI / 2,
viewportWidth / viewportHeight,
0.01,
1000
);
if (aspectRatio < 1) {
m = mat4.scale(m, m, vec3.fromValues(1, 1, 1 / aspectRatio));
}
return m;
}
}, },
count: regl.prop("count"), count: regl.prop("count"),
+163 -82
View File
@@ -3,7 +3,9 @@ import React from "react";
import * as d3 from "d3"; import * as d3 from "d3";
import { connect } from "react-redux"; import { connect } from "react-redux";
import mat4 from "gl-mat4"; import mat4 from "gl-mat4";
import vec3 from "gl-vec3";
import _regl from "regl"; import _regl from "regl";
import memoize from "memoize-one";
import { import {
Button, Button,
AnchorButton, AnchorButton,
@@ -13,7 +15,9 @@ import {
MenuItem, MenuItem,
Position, Position,
NumericInput, NumericInput,
Icon Icon,
RadioGroup,
Radio
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import * as globals from "../../globals"; import * as globals from "../../globals";
@@ -47,7 +51,8 @@ import { World } from "../../util/stateManager";
undoDisabled: state["@@undoable/past"].length === 0, undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0, redoDisabled: state["@@undoable/future"].length === 0,
selectionTool: state.graphSelection.tool, selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection currentSelection: state.graphSelection.selection,
layoutChoice: state.layoutChoice
})) }))
class Graph extends React.Component { class Graph extends React.Component {
static isValidDigitKeyEvent(e) { static isValidDigitKeyEvent(e) {
@@ -74,6 +79,38 @@ class Graph extends React.Component {
return key >= 0 && key <= 9; return key >= 0 && key <= 9;
} }
computePointPositions = memoize((X, Y, scaleX, scaleY) => {
/*
compute webgl coordinate buffer for each point
*/
const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) {
positions[2 * i] = scaleX(X[i]);
positions[2 * i + 1] = scaleY(Y[i]);
}
return positions;
});
computePointColors = memoize(rgb => {
/*
compute webgl colors for each point
*/
const colors = new Float32Array(3 * rgb.length);
for (let i = 0, len = rgb.length; i < len; i += 1) {
colors.set(rgb[i], 3 * i);
}
return colors;
});
computePointSizes = memoize((len, crossfilter) => {
/*
compute webgl dot size for each point
*/
const sizes = new Float32Array(len);
crossfilter.fillByIsSelected(sizes, 4, 0.2);
return sizes;
});
constructor(props) { constructor(props) {
super(props); super(props);
this.count = 0; this.count = 0;
@@ -81,6 +118,8 @@ class Graph extends React.Component {
this.graphPaddingBottom = 45; this.graphPaddingBottom = 45;
this.graphPaddingRight = globals.leftSidebarWidth; this.graphPaddingRight = globals.leftSidebarWidth;
this.renderCache = { this.renderCache = {
X: null,
Y: null,
positions: null, positions: null,
colors: null, colors: null,
sizes: null sizes: null
@@ -106,6 +145,12 @@ class Graph extends React.Component {
const colorBuffer = regl.buffer(); const colorBuffer = regl.buffer();
const sizeBuffer = regl.buffer(); const sizeBuffer = regl.buffer();
// preallocate coordinate system transformation between data and gl
const transform = {
glScaleX: scaleLinear([0, 1], [-1, 1]),
glScaleY: scaleLinear([0, 1], [1, -1])
};
/* first time, but this duplicates above function, should be possile to avoid this */ /* first time, but this duplicates above function, should be possile to avoid this */
const reglRender = regl.frame(() => { const reglRender = regl.frame(() => {
this.reglDraw( this.reglDraw(
@@ -128,7 +173,8 @@ class Graph extends React.Component {
colorBuffer, colorBuffer,
sizeBuffer, sizeBuffer,
camera, camera,
reglRender reglRender,
transform
}); });
} }
@@ -140,19 +186,10 @@ class Graph extends React.Component {
colorRGB, colorRGB,
responsive, responsive,
selectionTool, selectionTool,
currentSelection currentSelection,
layoutChoice
} = this.props; } = this.props;
const { const { reglRender, mode, regl, svg } = this.state;
reglRender,
mode,
regl,
drawPoints,
camera,
pointBuffer,
colorBuffer,
sizeBuffer,
svg
} = this.state;
let stateChanges = {}; let stateChanges = {};
if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") { if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") {
@@ -163,53 +200,40 @@ class Graph extends React.Component {
if (regl && world) { if (regl && world) {
/* update the regl state */ /* update the regl state */
const { obsLayout, nObs } = world; const { obsLayout, nObs } = world;
const X = obsLayout.col("X").asArray(); const {
const Y = obsLayout.col("Y").asArray(); drawPoints,
transform,
camera,
pointBuffer,
colorBuffer,
sizeBuffer
} = this.state;
// X/Y positions for each point - a cached value that only /* coordinates for each point */
// changes if we have loaded entirely new cell data const { glScaleX, glScaleY } = transform;
// const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
if (!renderCache.positions || world !== prevProps.world) { const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
renderCache.positions = new Float32Array(2 * nObs); const newPositions = this.computePointPositions(X, Y, glScaleX, glScaleY);
if (renderCache.positions !== newPositions) {
const glScaleX = scaleLinear([0, 1], [-1, 1]); /* update our cache & GL if the buffer changes */
const glScaleY = scaleLinear([0, 1], [1, -1]); renderCache.positions = newPositions;
pointBuffer({ data: newPositions, dimension: 2 });
const offset = [d3.mean(X) - 0.5, d3.mean(Y) - 0.5];
for (let i = 0, { positions } = renderCache; i < nObs; i += 1) {
positions[2 * i] = glScaleX(X[i] - offset[0]);
positions[2 * i + 1] = glScaleY(Y[i] - offset[1]);
}
pointBuffer({
data: renderCache.positions,
dimension: 2
});
stateChanges.offset = offset;
} }
// Colors for each point - a cached value that only changes when /* colors for each point */
// the cell metadata changes. const newColors = this.computePointColors(colorRGB);
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) { if (renderCache.colors !== newColors) {
const rgb = colorRGB; /* update our cache & GL if the buffer changes */
if (!renderCache.colors) { renderCache.colors = newColors;
renderCache.colors = new Float32Array(3 * rgb.length); colorBuffer({ data: newColors, dimension: 3 });
}
for (let i = 0, { colors } = renderCache; i < rgb.length; i += 1) {
colors.set(rgb[i], 3 * i);
}
colorBuffer({ data: renderCache.colors, dimension: 3 });
} }
// Sizes for each point - updates are triggered only when selected /* sizes for each point */
// obs change const newSizes = this.computePointSizes(nObs, crossfilter);
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) { if (renderCache.sizes !== newSizes) {
if (!renderCache.sizes) { /* update our cache & GL if the buffer changes */
renderCache.sizes = new Float32Array(nObs); renderCache.size = newSizes;
} sizeBuffer({ data: newSizes, dimension: 1 });
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
} }
this.count = nObs; this.count = nObs;
@@ -271,11 +295,10 @@ class Graph extends React.Component {
mode !== prevState.mode || mode !== prevState.mode ||
stateChanges.svg stateChanges.svg
) { ) {
const { tool, container, offset } = this.state; const { tool, container } = this.state;
this.selectionToolUpdate( this.selectionToolUpdate(
stateChanges.tool ? stateChanges.tool : tool, stateChanges.tool ? stateChanges.tool : tool,
stateChanges.container ? stateChanges.container : container, stateChanges.container ? stateChanges.container : container
stateChanges.offset ? stateChanges.offset : offset
); );
} }
@@ -435,7 +458,15 @@ class Graph extends React.Component {
this.setState({ pendingClipPercentiles: null }); this.setState({ pendingClipPercentiles: null });
}; };
brushToolUpdate(tool, container, offset) { handleLayoutChoiceChange = e => {
const { dispatch } = this.props;
dispatch({
type: "set layout choice",
layoutChoice: e.currentTarget.value
});
};
brushToolUpdate(tool, container) {
/* /*
this is called from componentDidUpdate(), so be very careful using this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously. anything from this.state, which may be updated asynchronously.
@@ -449,8 +480,8 @@ class Graph extends React.Component {
if there is a selection, make sure the brush tool matches if there is a selection, make sure the brush tool matches
*/ */
const screenCoords = [ const screenCoords = [
this.mapPointToScreen(currentSelection.brushCoords.northwest, offset), this.mapPointToScreen(currentSelection.brushCoords.northwest),
this.mapPointToScreen(currentSelection.brushCoords.southeast, offset) this.mapPointToScreen(currentSelection.brushCoords.southeast)
]; ];
if (!toolCurrentSelection) { if (!toolCurrentSelection) {
/* tool is not selected, so just move the brush */ /* tool is not selected, so just move the brush */
@@ -477,7 +508,7 @@ class Graph extends React.Component {
} }
} }
lassoToolUpdate(tool, container, offset) { lassoToolUpdate(tool, container) {
/* /*
this is called from componentDidUpdate(), so be very careful using this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously. anything from this.state, which may be updated asynchronously.
@@ -488,7 +519,7 @@ class Graph extends React.Component {
if there is a current selection, make sure the lasso tool matches if there is a current selection, make sure the lasso tool matches
*/ */
const polygon = currentSelection.polygon.map(p => const polygon = currentSelection.polygon.map(p =>
this.mapPointToScreen(p, offset) this.mapPointToScreen(p)
); );
tool.move(polygon); tool.move(polygon);
} else { } else {
@@ -496,7 +527,7 @@ class Graph extends React.Component {
} }
} }
selectionToolUpdate(tool, container, offset) { selectionToolUpdate(tool, container) {
/* /*
this is called from componentDidUpdate(), so be very careful using this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously. anything from this.state, which may be updated asynchronously.
@@ -504,10 +535,10 @@ class Graph extends React.Component {
const { selectionTool } = this.props; const { selectionTool } = this.props;
switch (selectionTool) { switch (selectionTool) {
case "brush": case "brush":
this.brushToolUpdate(tool, container, offset); this.brushToolUpdate(tool, container);
break; break;
case "lasso": case "lasso":
this.lassoToolUpdate(tool, container, offset); this.lassoToolUpdate(tool, container);
break; break;
default: default:
/* punt? */ /* punt? */
@@ -564,12 +595,14 @@ class Graph extends React.Component {
accounting for current pan/zoom camera. accounting for current pan/zoom camera.
*/ */
const { responsive } = this.props; const { responsive } = this.props;
const { regl, camera, offset } = this.state; const { regl, camera, transform } = this.state;
const { glScaleX, glScaleY } = transform;
const gl = regl._gl; const gl = regl._gl;
// get aspect ratio // get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight; const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
const scale = aspect < 1 ? 1 / aspect : 1;
// compute inverse view matrix // compute inverse view matrix
const inverse = mat4.invert([], camera.view()); const inverse = mat4.invert([], camera.view());
@@ -578,37 +611,37 @@ class Graph extends React.Component {
const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1; const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1; const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [ const pout = [
x * inverse[14] * aspect + inverse[12], x * inverse[14] * aspect * scale + inverse[12],
y * inverse[14] + inverse[13] -(y * inverse[14] * scale + inverse[13])
]; ];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]]; const xy = [glScaleX.invert(pout[0]), glScaleY.invert(pout[1])];
return xy;
} }
mapPointToScreen(xyCell, offset) { mapPointToScreen(xyCell) {
/* /*
Map an XY coordinate from cell/point domain to screen range. Inverse Map an XY coordinate from cell/point domain to screen range. Inverse
of mapScreenToPoint() of mapScreenToPoint()
*/ */
const { responsive } = this.props; const { responsive } = this.props;
const { regl, camera } = this.state; const { regl, camera, transform } = this.state;
const { glScaleX, glScaleY } = transform;
const gl = regl._gl; const gl = regl._gl;
// get aspect ratio // get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight; const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
const scale = aspect < 1 ? 1 / aspect : 1;
// compute inverse view matrix // compute inverse view matrix
const inverse = mat4.invert([], camera.view()); let inverse = mat4.invert([], camera.view());
// variable names are choosen to reflect inverse of those used // variable names are choosen to reflect inverse of those used
// in mapScreenToPoint(). // in mapScreenToPoint().
const pout = [ const pout = [glScaleX(xyCell[0]), glScaleY(xyCell[1])];
(xyCell[0] - offset[0]) * 2 - 1, const x = (pout[0] - inverse[12]) / aspect / scale / inverse[14];
(xyCell[1] - offset[1]) * 2 - 1 const y = (-pout[1] - inverse[13]) / scale / inverse[14];
];
const x = (pout[0] - inverse[12]) / aspect / inverse[14];
const y = (pout[1] - inverse[13]) / inverse[14];
const pin = [ const pin = [
Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2), Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2),
@@ -745,7 +778,8 @@ class Graph extends React.Component {
redoDisabled, redoDisabled,
selectionTool, selectionTool,
clipPercentileMin, clipPercentileMin,
clipPercentileMax clipPercentileMax,
layoutChoice
} = this.props; } = this.props;
const { mode, pendingClipPercentiles } = this.state; const { mode, pendingClipPercentiles } = this.state;
@@ -888,6 +922,7 @@ class Graph extends React.Component {
/> />
</Tooltip> </Tooltip>
</div> </div>
<div <div
className="bp3-button-group" className="bp3-button-group"
style={{ style={{
@@ -898,6 +933,49 @@ class Graph extends React.Component {
target={ target={
<Button <Button
type="button" type="button"
data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap"
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<RadioGroup
label="Layout Choice"
onChange={this.handleLayoutChoiceChange}
selectedValue={layoutChoice.current}
>
{layoutChoice.available.map(name => (
<Radio label={name} value={name} key={name} />
))}
</RadioGroup>
</div>
}
/>
</div>
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`} className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{ style={{
cursor: "pointer" cursor: "pointer"
@@ -929,6 +1007,7 @@ class Graph extends React.Component {
> >
<NumericInput <NumericInput
style={{ width: 50 }} style={{ width: 50 }}
data-testid={"clip-min-input"}
onValueChange={this.handleClipPercentileMinValueChange} onValueChange={this.handleClipPercentileMinValueChange}
onKeyPress={this.handleClipOnKeyPress} onKeyPress={this.handleClipOnKeyPress}
value={clipMin} value={clipMin}
@@ -949,6 +1028,7 @@ class Graph extends React.Component {
<span style={{ marginRight: 5, marginLeft: 5 }}> - </span> <span style={{ marginRight: 5, marginLeft: 5 }}> - </span>
<NumericInput <NumericInput
style={{ width: 50 }} style={{ width: 50 }}
data-testid={"clip-max-input"}
onValueChange={this.handleClipPercentileMaxValueChange} onValueChange={this.handleClipPercentileMaxValueChange}
onKeyPress={this.handleClipOnKeyPress} onKeyPress={this.handleClipOnKeyPress}
value={clipMax} value={clipMax}
@@ -968,6 +1048,7 @@ class Graph extends React.Component {
/> />
<Button <Button
type="button" type="button"
data-testid="clip-commit"
className="bp3-button" className="bp3-button"
disabled={this.isClipDisabled()} disabled={this.isClipDisabled()}
style={{ style={{
+38 -10
View File
@@ -1,5 +1,4 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import Categorical from "./categorical/categorical"; import Categorical from "./categorical/categorical";
@@ -7,10 +6,11 @@ import Continuous from "./continuous/continuous";
import GeneExpression from "./geneExpression"; import GeneExpression from "./geneExpression";
import * as globals from "../globals"; import * as globals from "../globals";
import DynamicScatterplot from "./scatterplot/scatterplot"; import DynamicScatterplot from "./scatterplot/scatterplot";
import Logo from "./framework/logo.js";
@connect(state => ({ @connect(state => ({
responsive: state.responsive, responsive: state.responsive,
datasetTitle: _.get(state.config, "displayNames.dataset"), datasetTitle: state.config?.displayNames?.dataset,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor, scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor scatterplotYYaccessor: state.controls.scatterplotYYaccessor
})) }))
@@ -28,7 +28,6 @@ class LeftSideBar extends React.Component {
if cellxgene logo or tabs change, this must as well if cellxgene logo or tabs change, this must as well
*/ */
const metadataSectionPadding = 0; const metadataSectionPadding = 0;
// scatterplotXXaccessor && scatterplotYYaccessor ? 450 : 0;
return ( return (
<div <div
@@ -40,20 +39,49 @@ class LeftSideBar extends React.Component {
}} }}
> >
<p <p
data-testid="header"
style={{ style={{
position: "fixed", position: "fixed",
top: globals.cellxgeneTitleTopPadding, top: globals.cellxgeneTitleTopPadding,
left: globals.leftSidebarWidth + globals.cellxgeneTitleLeftPadding, left: globals.leftSidebarWidth + globals.cellxgeneTitleLeftPadding,
margin: 0, margin: 0
fontSize: globals.largestFontSize,
color: globals.darkerGrey,
width: "100%"
}} }}
> >
cellxgene: {datasetTitle} <Logo size={32} />
<span
style={{
fontSize: 28,
position: "relative",
top: -4,
fontWeight: "bold",
marginLeft: 5,
color: globals.logoColor,
userSelect: "none"
}}
>
cell<span
style={{
position: "relative",
top: 1,
fontWeight: 300,
fontSize: 24
}}
>
×
</span>gene
</span>
<span
data-testid="header"
style={{
fontSize: 16,
display: "block",
position: "relative",
marginTop: 10,
top: -4
}}
>
{datasetTitle}
</span>
</p> </p>
<div <div
style={{ style={{
height: responsive.height - metadataSectionPadding, height: responsive.height - metadataSectionPadding,
+1
View File
@@ -31,6 +31,7 @@ export const darkGreen = "#448C4D";
export const nonFiniteCellColor = lightGrey; export const nonFiniteCellColor = lightGrey;
export const defaultCellColor = "rgb(0,0,0,1)"; export const defaultCellColor = "rgb(0,0,0,1)";
export const logoColor = "black"; /* logo pink: "#E9429A" */
/* typography constants */ /* typography constants */
+16 -17
View File
@@ -1,12 +1,9 @@
import _ from "lodash";
import { ControlsHelpers } from "../util/stateManager"; import { ControlsHelpers } from "../util/stateManager";
import * as globals from "../globals"; import * as globals from "../globals";
function maxCategoryItems(state) { function maxCategoryItems(state) {
return _.get( return (
state.config, state.config.parameters?.["max-category-items"] ??
"parameters.max-category-items",
globals.configDefaults.parameters["max-category-items"] globals.configDefaults.parameters["max-category-items"]
); );
} }
@@ -33,15 +30,15 @@ const CategoricalSelection = (
/* /*
Set the specific category in this field to false Set the specific category in this field to false
*/ */
const newCategorySelected = Array.from( const newCategoryValueSelected = Array.from(
state[action.metadataField].categorySelected state[action.metadataField].categoryValueSelected
); );
newCategorySelected[action.categoryIndex] = true; newCategoryValueSelected[action.categoryIndex] = true;
const newCategoricalSelection = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: newCategorySelected categoryValueSelected: newCategoryValueSelected
} }
}; };
return newCategoricalSelection; return newCategoricalSelection;
@@ -51,15 +48,15 @@ const CategoricalSelection = (
/* /*
Set the specific category in this field to false Set the specific category in this field to false
*/ */
const newCategorySelected = Array.from( const newCategoryValueSelected = Array.from(
state[action.metadataField].categorySelected state[action.metadataField].categoryValueSelected
); );
newCategorySelected[action.categoryIndex] = false; newCategoryValueSelected[action.categoryIndex] = false;
const newCategoricalSelection = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: newCategorySelected categoryValueSelected: newCategoryValueSelected
} }
}; };
return newCategoricalSelection; return newCategoricalSelection;
@@ -73,8 +70,9 @@ const CategoricalSelection = (
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: Array.from( categorySelected: false,
state[action.metadataField].categorySelected categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(false) ).fill(false)
} }
}; };
@@ -89,8 +87,9 @@ const CategoricalSelection = (
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: Array.from( categorySelected: true,
state[action.metadataField].categorySelected categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(true) ).fill(true)
} }
}; };
+2 -1
View File
@@ -93,9 +93,10 @@ const Controls = (
} }
case "request differential expression success": { case "request differential expression success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const _diffexpGenes = []; const _diffexpGenes = [];
action.data.forEach(d => { action.data.forEach(d => {
_diffexpGenes.push(world.varAnnotations.at(d[0], "name")); _diffexpGenes.push(world.varAnnotations.at(d[0], varIndexName));
}); });
return { return {
...state, ...state,
+33 -7
View File
@@ -20,10 +20,11 @@ const CrossfilterReducer = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "initial data load complete (universe exists)": { case "initial data load complete (universe exists)": {
const { world } = nextSharedState; const { world, layoutChoice } = nextSharedState;
const crossfilter = World.createObsDimensions( const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations), new Crossfilter(world.obsAnnotations),
world world,
layoutChoice.currentDimNames
); );
return crossfilter; return crossfilter;
} }
@@ -43,9 +44,13 @@ const CrossfilterReducer = (
case "set clip quantiles": case "set clip quantiles":
case "set World to current selection": { case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls; const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState; const { world, layoutChoice } = nextSharedState;
let crossfilter = new Crossfilter(world.obsAnnotations); let crossfilter = new Crossfilter(world.obsAnnotations);
crossfilter = World.createObsDimensions(crossfilter, world); crossfilter = World.createObsDimensions(
crossfilter,
world,
layoutChoice.currentDimNames
);
crossfilter = ControlsHelpers.createGeneDimensions( crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes, userDefinedGenes,
diffexpGenes, diffexpGenes,
@@ -55,6 +60,23 @@ const CrossfilterReducer = (
return crossfilter; return crossfilter;
} }
case "set layout choice": {
/*
when switching layouts:
- delete the existing XY index
- add the new XY index (which implicitly selects all on it)
*/
const { world, layoutChoice } = nextSharedState;
return state
.delDimension(layoutDimensionName("XY"))
.addDimension(
layoutDimensionName("XY"),
"spatial",
world.obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
world.obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
);
}
case "request user defined gene success": { case "request user defined gene success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const gene = action.data.genes[0]; const gene = action.data.genes[0];
@@ -68,8 +90,9 @@ const CrossfilterReducer = (
case "request differential expression success": { case "request differential expression success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const genes = _.map(action.data, d => const genes = _.map(action.data, d =>
world.varAnnotations.at(d[0], "name") world.varAnnotations.at(d[0], varIndexName)
); );
const crossfilter = _.reduce( const crossfilter = _.reduce(
genes, genes,
@@ -87,10 +110,11 @@ const CrossfilterReducer = (
case "clear differential expression": { case "clear differential expression": {
const { world } = prevSharedState; const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const crossfilter = _.reduce( const crossfilter = _.reduce(
action.diffExp, action.diffExp,
(xfltr, values) => { (xfltr, values) => {
const name = world.varAnnotations.at(values[0], "name"); const name = world.varAnnotations.at(values[0], varIndexName);
return xfltr.delDimension(diffexpDimensionName(name)); return xfltr.delDimension(diffexpDimensionName(name));
}, },
state state
@@ -161,10 +185,12 @@ const CrossfilterReducer = (
case "categorical metadata filter select": case "categorical metadata filter select":
case "categorical metadata filter deselect": { case "categorical metadata filter deselect": {
const { categoricalSelection } = nextSharedState; const { categoricalSelection } = nextSharedState;
const { world } = prevSharedState;
const cat = categoricalSelection[action.metadataField]; const cat = categoricalSelection[action.metadataField];
const col = world.obsAnnotations.col(action.metadataField);
return state.select(obsAnnoDimensionName(action.metadataField), { return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact", mode: "exact",
values: ControlsHelpers.selectedValuesForCategory(cat) values: ControlsHelpers.selectedValuesForCategory(cat, col)
}); });
} }
+2 -1
View File
@@ -7,7 +7,8 @@ const GraphSelection = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "set clip quantiles": case "set clip quantiles":
case "reset World to eq Universe": { case "reset World to eq Universe":
case "set layout choice": {
return { return {
...state, ...state,
selection: { selection: {
+28 -25
View File
@@ -12,6 +12,7 @@ import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter"; import crossfilter from "./crossfilter";
import colors from "./colors"; import colors from "./colors";
import differential from "./differential"; import differential from "./differential";
import layoutChoice from "./layoutChoice";
import responsive from "./responsive"; import responsive from "./responsive";
import controls from "./controls"; import controls from "./controls";
import resetCache from "./resetCache"; import resetCache from "./resetCache";
@@ -19,31 +20,33 @@ import resetCache from "./resetCache";
import undoableConfig from "./undoableConfig"; import undoableConfig from "./undoableConfig";
const Reducer = undoable( const Reducer = undoable(
cascadeReducers([ cascadeReducers([
["config", config], ["config", config],
["universe", universe], ["universe", universe],
["world", world], ["world", world],
["categoricalSelection", categoricalSelection], ["layoutChoice", layoutChoice],
["continuousSelection", continuousSelection], ["categoricalSelection", categoricalSelection],
["graphSelection", graphSelection], ["continuousSelection", continuousSelection],
["crossfilter", crossfilter], ["graphSelection", graphSelection],
["colors", colors], ["crossfilter", crossfilter],
["controls", controls], ["colors", colors],
["differential", differential], ["controls", controls],
["responsive", responsive], ["differential", differential],
["resetCache", resetCache] ["responsive", responsive],
]), ["resetCache", resetCache]
[ ]),
"world", [
"categoricalSelection", "world",
"continuousSelection", "categoricalSelection",
"graphSelection", "continuousSelection",
"crossfilter", "graphSelection",
"colors", "crossfilter",
"controls", "colors",
"differential" "controls",
], "differential",
undoableConfig "layoutChoice"
],
undoableConfig
); );
const store = createStore(Reducer, applyMiddleware(thunk)); const store = createStore(Reducer, applyMiddleware(thunk));
+50
View File
@@ -0,0 +1,50 @@
/*
we have a UI heuristic to pick the default layout, based on assumptions
about commonly used names. Preferentially, pick in the following order:
1. "umap"
2. "tsne"
3. "pca"
4. give up, use the first available
*/
function bestDefaultLayout(layouts) {
const preferredNames = ["umap", "tsne", "pca"];
const idx = preferredNames.findIndex(name => layouts.indexOf(name) !== -1);
if (idx !== -1) return preferredNames[idx];
return layouts[0];
}
const LayoutChoice = (
state = {
available: [], // all available choices
current: undefined, // name of the current layout, eg, 'umap'
currentDimNames: [] // dimension name
},
action,
nextSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "reset World to eq Universe": {
// set default to default
const { schema } = nextSharedState.world;
const available = schema.layout.obs.map(v => v.name);
const current = bestDefaultLayout(available);
const currentDimNames = schema.layout.obsByName[current].dims;
return { available, current, currentDimNames };
}
case "set layout choice": {
const { schema } = nextSharedState.world;
const current = action.layoutChoice;
const currentDimNames = schema.layout.obsByName[current].dims;
return { ...state, current, currentDimNames };
}
default: {
return state;
}
}
};
export default LayoutChoice;
+12 -4
View File
@@ -59,7 +59,7 @@ const saveOnActions = new Set([
"categorical metadata filter select", "categorical metadata filter select",
"categorical metadata filter deselect", "categorical metadata filter deselect",
"categorical metadata filter all of these", "categorical metadata filter all of these",
"categorical metadata none of these", "categorical metadata filter none of these",
"color by categorical metadata", "color by categorical metadata",
"color by continuous metadata", "color by continuous metadata",
@@ -72,7 +72,9 @@ const saveOnActions = new Set([
"store current cell selection as differential set 2", "store current cell selection as differential set 2",
"set World to current selection", "set World to current selection",
"set clip quantiles" "set clip quantiles",
"set layout choice"
]); ]);
/** /**
@@ -98,9 +100,15 @@ const applyPending = () => ({
[actionKey]: "applyPending", [actionKey]: "applyPending",
[stateKey]: { fsm: null } [stateKey]: { fsm: null }
}); });
const skip = fsm => ({ [actionKey]: "skip", [stateKey]: { fsm } }); const skip = (fsm, transition) => ({
[actionKey]: "skip",
[stateKey]: { fsm: transition.to !== "done" ? fsm : null }
});
const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } }); const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } });
const save = fsm => ({ [actionKey]: "save", [stateKey]: { fsm } }); const save = (fsm, transition) => ({
[actionKey]: "save",
[stateKey]: { fsm: transition.to !== "done" ? fsm : null }
});
/* /*
Error handler for state transitions that are unexpected. Called by Error handler for state transitions that are unexpected. Called by
+1 -2
View File
@@ -5,10 +5,9 @@ const mp = require("mouse-position");
const mb = require("mouse-pressed"); const mb = require("mouse-pressed");
const key = require("key-pressed"); const key = require("key-pressed");
const panSpeed = 0.4; const panSpeed = 1.0; // changed from 0.4 to 1.0 per issue #722
const scaleSpeed = 0.5; const scaleSpeed = 0.5;
const scaleMax = 3; const scaleMax = 3;
// const scaleMin = 1.15
const scaleMin = 1.03; const scaleMin = 1.03;
function attachCamera(canvas, opts) { function attachCamera(canvas, opts) {
+2 -8
View File
@@ -3,6 +3,8 @@ Label indexing - map a label to & from an integer offset. See Dataframe
for how this is used. for how this is used.
**/ **/
import { rangeFill as fillRange } from "../range";
/* /*
Private utility functions Private utility functions
*/ */
@@ -21,14 +23,6 @@ function extent(tarr) {
return [min, max]; return [min, max];
} }
function fillRange(arr, start = 0) {
const larr = arr;
for (let i = 0, l = larr.length; i < l; i += 1) {
larr[i] = i + start;
}
return larr;
}
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
class IdentityInt32Index { class IdentityInt32Index {
/* /*
+45
View File
@@ -0,0 +1,45 @@
/*
Array range creation
range(start, stop, step) -> Array
This is identical to https://docs.python.org/3/library/functions.html#func-range
Returns new array filled with a range of numbers.
Usage:
range(stop) - start defaults to zero, step defaults to 1
range(start, stop, [step]) - step defaults to 1
Examples:
range(3) -> [0, 1, 2]
range(1, 3) -> [1, 2]
range(1, 5, 2) -> [1, 3]
rangeFill(array, start, step) -> array
Fill entire array with values, from start, by step. Returns first array.
start defaults to zero, step defaults to one.
*/
function _doFill(arr, start, step, count) {
for (let idx = 0, val = start; idx < count; idx += 1, val += step) {
arr[idx] = val;
}
return arr;
}
export function rangeFill(arr, start = 0, step = 1) {
return _doFill(arr, start, step, arr.length);
}
export function range(start, stop, step) {
if (start === undefined) return [];
if (stop === undefined) {
stop = start;
start = 0;
}
step = step || 1; // catch undefind and zero
const len = Math.max(Math.ceil((stop - start) / step), 0);
return _doFill(new Array(len), start, step, len);
}
+10 -4
View File
@@ -9,8 +9,14 @@
// this is is equivalent to d3.scaleLinear().domain([0,1]).range([-1,1]) // this is is equivalent to d3.scaleLinear().domain([0,1]).range([-1,1])
export default (domain, range) => { export default (domain, range) => {
const domainStart = domain[0]; const domainStart = domain[0];
const scale = (range[1] - range[0]) / (domain[1] - domain[0]); const scale = (range[1] - range[0]) / (domain[1] - domain[0]);
const rangeStart = range[0]; const invScale = 1 / scale;
return value => (value - domainStart) * scale + rangeStart; const rangeStart = range[0];
const f = value => (value - domainStart) * scale + rangeStart;
// inverter
f.invert = value => (value - rangeStart) * invScale + domainStart;
return f;
}; };
+4 -6
View File
@@ -1,12 +1,12 @@
/* /*
Helper functions for the embedded graph colors Helper functions for the embedded graph colors
*/ */
import _ from "lodash";
import * as d3 from "d3"; import * as d3 from "d3";
import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic"; import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic";
import * as globals from "../../globals"; import * as globals from "../../globals";
import parseRGB from "../parseRGB"; import parseRGB from "../parseRGB";
import finiteExtent from "../finiteExtent"; import finiteExtent from "../finiteExtent";
import { range } from "../range";
/* /*
create new colors state object. Paramters: create new colors state object. Paramters:
@@ -37,9 +37,7 @@ function createColors(world, colorMode = null, colorAccessor = null) {
} }
function createColorsByCategoricalMetadata(world, accessor) { function createColorsByCategoricalMetadata(world, accessor) {
const { categories } = _.filter(world.schema.annotations.obs, { const { categories } = world.schema.annotations.obsByName[accessor];
name: accessor
})[0];
const scale = d3 const scale = d3
.scaleSequential(interpolateRainbow) .scaleSequential(interpolateRainbow)
@@ -67,7 +65,7 @@ function createColorsByContinuousMetadata(world, accessor) {
const scale = d3 const scale = d3
.scaleQuantile() .scaleQuantile()
.domain([min, max]) .domain([min, max])
.range(_.range(colorBins - 1, -1, -1)); .range(range(colorBins - 1, -1, -1));
/* pre-create colors - much faster than doing it for each obs */ /* pre-create colors - much faster than doing it for each obs */
const colors = new Array(colorBins); const colors = new Array(colorBins);
@@ -97,7 +95,7 @@ function createColorsByExpression(world, accessor) {
const scale = d3 const scale = d3
.scaleQuantile() .scaleQuantile()
.domain([min, max]) .domain([min, max])
.range(_.range(colorBins - 1, -1, -1)); .range(range(colorBins - 1, -1, -1));
/* pre-create colors - much faster than doing it for each obs */ /* pre-create colors - much faster than doing it for each obs */
const colors = new Array(colorBins); const colors = new Array(colorBins);
+37 -19
View File
@@ -5,7 +5,7 @@ Helper functions for the controls reducer
import _ from "lodash"; import _ from "lodash";
import * as globals from "../../globals"; import * as globals from "../../globals";
import { fillRange } from "../typedCrossfilter/util"; import { rangeFill as fillRange } from "../range";
import { import {
userDefinedDimensionName, userDefinedDimensionName,
diffexpDimensionName diffexpDimensionName
@@ -21,16 +21,16 @@ Remember that option values can be ANY js type, except undefined/null.
{ {
_category_name_1: { _category_name_1: {
// map of option value to index // map of option value to index
categoryIndices: Map([ categoryValueIndices: Map([
catval1: index, catval1: index,
... ...
]) ])
// index->selection true/false state // index->selection true/false state
categorySelected: [ true/false, true/false, ... ] categoryValueSelected: [ true/false, true/false, ... ]
// number of options // number of options
numCategories: number, numCategoryValues: number,
// isTruncated - true if the options for selection has // isTruncated - true if the options for selection has
// been truncated (ie, was too large to implement) // been truncated (ie, was too large to implement)
@@ -56,27 +56,31 @@ function topNCategories(summary) {
export function createCategoricalSelection(maxCategoryItems, world) { export function createCategoricalSelection(maxCategoryItems, world) {
const res = {}; const res = {};
const obsIndexName = world.schema.annotations.obs.index;
_.forEach(world.obsAnnotations.colIndex.keys(), key => { _.forEach(world.obsAnnotations.colIndex.keys(), key => {
const summary = world.obsAnnotations.col(key).summarize(); const summary = world.obsAnnotations.col(key).summarize();
if (summary.categories) { if (summary.categories) {
const isColorField = key.includes("color") || key.includes("Color"); const isColorField = key.includes("color") || key.includes("Color");
const isSelectableCategory = const isSelectableCategory =
!isColorField && !isColorField &&
key !== "name" && key !== obsIndexName &&
summary.categories.length < maxCategoryItems; summary.categories.length < maxCategoryItems;
if (isSelectableCategory) { if (isSelectableCategory) {
const [categoryValues, categoryCounts] = topNCategories(summary); const [categoryValues, categoryValueCounts] = topNCategories(summary);
const categoryIndices = new Map(categoryValues.map((v, i) => [v, i])); const categoryValueIndices = new Map(
const numCategories = categoryIndices.size; categoryValues.map((v, i) => [v, i])
const categorySelected = new Array(numCategories).fill(true); );
const numCategoryValues = categoryValueIndices.size;
const categoryValueSelected = new Array(numCategoryValues).fill(true);
const isTruncated = categoryValues.length < summary.numCategories; const isTruncated = categoryValues.length < summary.numCategories;
res[key] = { res[key] = {
categoryValues, // array: of natively typed category values categoryValues, // array: of natively typed category values
categoryIndices, // map: category value (native type) -> category index categoryValueIndices, // map: category value (native type) -> category index
categorySelected, // array: t/f selection state categoryValueSelected, // array: t/f selection state
numCategories, // number: of categories numCategoryValues, // number: of values in the category
isTruncated, // bool: true if list was truncated isTruncated, // bool: true if list was truncated
categoryCounts // array: cardinality of each category categoryValueCounts, // array: cardinality of each category,
categorySelected: true // bool - default state for entire category
}; };
} }
} }
@@ -88,12 +92,26 @@ export function createCategoricalSelection(maxCategoryItems, world) {
given a categoricalSelection, 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). where selection state is true (ie, they are selected).
*/ */
export function selectedValuesForCategory(categorySelectionState) { export function selectedValuesForCategory(categorySelectionState, dfColumn) {
const selectedValues = _([...categorySelectionState.categoryIndices]) const {
.filter(tuple => categorySelectionState.categorySelected[tuple[1]]) categorySelected,
.map(tuple => tuple[0]) categoryValueSelected,
.value(); categoryValueIndices
return selectedValues; } = categorySelectionState;
let selectedValues;
if (categorySelected) {
selectedValues = new Set(dfColumn.summarize().categories);
} else {
selectedValues = new Set();
}
categoryValueIndices.forEach((catIndex, catValue) => {
if (!categoryValueSelected[catIndex]) {
selectedValues.delete(catValue);
} else {
selectedValues.add(catValue);
}
});
return [...selectedValues.values()];
} }
/* /*
+20 -8
View File
@@ -78,6 +78,7 @@ function AnnotationsFBSToDataframe(arrayBuffer) {
The application has strong assumptions that all scalar data will be The application has strong assumptions that all scalar data will be
stored as a float32 or float64 (regardless of underlying data types). stored as a float32 or float64 (regardless of underlying data types).
For example, clipping of value ranges (eg, user-selected percentiles) For example, clipping of value ranges (eg, user-selected percentiles)
depends on the ability to use NaN in any numeric type.
All float data from the server is left as is. All non-float is promoted All float data from the server is left as is. All non-float is promoted
to an appropriate float. to an appropriate float.
@@ -98,15 +99,16 @@ function AnnotationsFBSToDataframe(arrayBuffer) {
function LayoutFBSToDataframe(arrayBuffer) { function LayoutFBSToDataframe(arrayBuffer) {
const fbs = decodeMatrixFBS(arrayBuffer, true); const fbs = decodeMatrixFBS(arrayBuffer, true);
if (fbs.columns.length !== 2 || !fbs.columns.every(isFpTypedArray)) { if (fbs.columns.length < 2 || !fbs.columns.every(isFpTypedArray)) {
// We have strong assumptions about the shape & type of layout data. // We have strong assumptions about the shape & type of layout data.
throw new Error("Unexpected layout data type returned from server"); throw new Error("Unexpected layout data type returned from server");
} }
const df = new Dataframe.Dataframe( const df = new Dataframe.Dataframe(
[fbs.nRows, fbs.nCols], [fbs.nRows, fbs.nCols],
fbs.columns, fbs.columns,
null, null,
new Dataframe.KeyIndex(["X", "Y"]) new Dataframe.KeyIndex(fbs.colIdx)
); );
return df; return df;
} }
@@ -122,15 +124,15 @@ function reconcileSchemaCategoriesWithSummary(universe) {
cases, add a 'categories' field to the schema so it is accessible. cases, add a 'categories' field to the schema so it is accessible.
*/ */
_.forEach(universe.schema.annotations.obs, s => { universe.schema.annotations.obs.columns.forEach(s => {
if ( if (
s.type === "string" || s.type === "string" ||
s.type === "boolean" || s.type === "boolean" ||
s.type === "categorical" s.type === "categorical"
) { ) {
const categories = _.union( const categories = _.union(
_.get(s, "categories", []), s.categories ?? [],
_.get(universe.obsAnnotations.col(s.name).summarize(), "categories", []) universe.obsAnnotations.col(s.name).summarize().categories ?? []
); );
s.categories = categories; s.categories = categories;
} }
@@ -154,6 +156,9 @@ export function createUniverseFromResponse(
universe.schema = schema; universe.schema = schema;
universe.nObs = schema.dataframe.nObs; universe.nObs = schema.dataframe.nObs;
universe.nVar = schema.dataframe.nVar; universe.nVar = schema.dataframe.nVar;
/* add defaults, as we can't assume back-end will fully populate schema */
if (!schema.layout.var) schema.layout.var = [];
if (!schema.layout.obs) schema.layout.obs = [];
/* annotations */ /* annotations */
universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse); universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse);
@@ -174,10 +179,16 @@ export function createUniverseFromResponse(
/* Index schema for ease of use */ /* Index schema for ease of use */
universe.schema.annotations.obsByName = fromEntries( universe.schema.annotations.obsByName = fromEntries(
universe.schema.annotations.obs.map(v => [v.name, v]) universe.schema.annotations.obs.columns.map(v => [v.name, v])
); );
universe.schema.annotations.varByName = fromEntries( universe.schema.annotations.varByName = fromEntries(
universe.schema.annotations.var.map(v => [v.name, v]) universe.schema.annotations.var.columns.map(v => [v.name, v])
);
universe.schema.layout.obsByName = fromEntries(
universe.schema.layout.obs.map(v => [v.name, v])
);
universe.schema.layout.varByName = fromEntries(
universe.schema.layout.var.map(v => [v.name, v])
); );
return universe; return universe;
} }
@@ -202,8 +213,9 @@ export function convertDataFBStoObject(universe, arrayBuffer) {
throw new Error("Unexpected non-floating point response from server."); throw new Error("Unexpected non-floating point response from server.");
} }
const varIndexName = universe.schema.annotations.var.index;
for (let c = 0; c < colIdx.length; c += 1) { for (let c = 0; c < colIdx.length; c += 1) {
const varName = universe.varAnnotations.at(colIdx[c], "name"); const varName = universe.varAnnotations.at(colIdx[c], varIndexName);
result[varName] = columns[c]; result[varName] = columns[c];
} }
return result; return result;
+9 -5
View File
@@ -260,13 +260,17 @@ function deduceDimensionType(attributes, fieldName) {
return dimensionType; return dimensionType;
} }
export function createObsDimensions(crossfilter, world) { export function createObsDimensions(crossfilter, world, XYdimNames) {
/* /*
create and return a crossfilter with a dimension for every obs annotation create and return a crossfilter with a dimension for every obs annotation
for which we have a supported type, *except* 'name' for which we have a supported type, *except* for the index column, indicated
by schema.annotations.obs.index.
*/ */
const { schema, obsLayout, obsAnnotations } = world; const { schema, obsLayout, obsAnnotations } = world;
const annoList = schema.annotations.obs.filter(anno => anno.name !== "name"); const indexName = schema.annotations.obs.index;
const annoList = schema.annotations.obs.columns.filter(
anno => anno.name !== indexName
);
crossfilter = annoList.reduce((xfltr, anno) => { crossfilter = annoList.reduce((xfltr, anno) => {
const dimType = deduceDimensionType(anno, anno.name); const dimType = deduceDimensionType(anno, anno.name);
const colData = obsAnnotations.col(anno.name).asArray(); const colData = obsAnnotations.col(anno.name).asArray();
@@ -283,8 +287,8 @@ export function createObsDimensions(crossfilter, world) {
return crossfilter.addDimension( return crossfilter.addDimension(
layoutDimensionName("XY"), layoutDimensionName("XY"),
"spatial", "spatial",
obsLayout.col("X").asArray(), obsLayout.col(XYdimNames[0]).asArray(),
obsLayout.col("Y").asArray() obsLayout.col(XYdimNames[1]).asArray()
); );
} }
+1 -11
View File
@@ -1,22 +1,12 @@
// jshint esversion: 6 // jshint esversion: 6
import { sortIndex } from "./sort"; import { sortIndex } from "./sort";
import { rangeFill as fillRange } from "../range";
/* /*
Utility functions, private to this module. Utility functions, private to this module.
*/ */
// fill an array or typedarray with a sequential range of numbers,
// starting with `start`
//
export function fillRange(arr, start = 0) {
const larr = arr;
for (let i = 0, len = larr.length; i < len; i += 1) {
larr[i] = i + start;
}
return larr;
}
// slice out of one array into another, using an index array // slice out of one array into another, using an index array
// //
export function sliceByIndex(src, index) { export function sliceByIndex(src, index) {
+91
View File
@@ -0,0 +1,91 @@
# Developer guidelines
### Requirements
- npm
- Python 3.6+
- Chrome
[See dev section of README](../README.md)
**All instructions are expected to be run from the top level cellxgene directory unless otherwise specified.**
## Server dev
### Install
* Build the client and put static files in place: `make build-for-server-dev`
* Install from local files: `make install-dev`
### Launch
* `cellxgene launch [options] <datafile>`
### Reloading
If you install cellxgene using `make install-dev` the server will be restarted every time you make changes on the server code. If changes affects the client, the browser must be reloaded.
### Linter
We use `flake8` to lint code. Travis CI runs `flake8 server`.
### Test
1. Install development requirements `pip install -r server/requirements-dev.txt`
2. Run tests `pytest server/test`
### Tips
* Install in a virtualenv
* May need to rebuild/reinstall when you make client changes
## Client dev
### Install
1. Install prereqs for client: `npm install --prefix client/ client`
2. Install cellxgene server: `pip install -e .` Caveat: this will not build the production client package - you must use the [server install](#install) instructions above to serve web assets.
### Launch
To launch with hot reloading you need to launch the server and the client separately. Node's hot reloading starts the client on its own node server and auto-refreshes when changes are made.
1. Launch server (the client relies on the REST API being available): `cellxgene launch [options] <datafile>`
2. Launch client: in `client/` directory run `npm run start`
3. Client will be served on localhost:3000
### Build
To build only the client: `make build-client`
### Linter
We use `eslint` to lint the code and `prettier` as our code formatter.
### Test
In `client/` directory run `npm run unit-test`
### Tips
* You can also install/launch the server side code from npm scrips (requires python3.6 with virtualenv) in `client/` directory run `npm run backend-dev`
## Running tests
Client and server tests run on Travis CI for every push, PR, and commit to master on github. End to end tests run nightly on master only.
### Server unit tests
Install development requirements `pip install -r server/requirements-dev.txt`
Run tests `pytest server/test`
### Client unit tests
In `client/` directory run `npm run unit-test`
### End to end tests
End to end tests use two env variables:
* `JEST_ENV` - environment to run end to end tests. Default `dev`
* `prod` - run headless with no slowdown, chromium will not open.
* `dev` - opens chromimum, runs tests with minimal slowdown, close on exit.
* `debug` - opens chromium, runs tests with 100ms slowdown, dev tools open, chrome stays open on exit.
* `JEST_CXG_PORT` - port that end to end tests are being run on. Default `3000` (client hosted port).
On CI the end to end tests are run with `JEST_ENV` set to `prod` using the `smoke-test` npm script
To run end to end tests as they will be run on CI
1. cellxgene should be built and installed as [specified in server dev](#install)
2. `export JEST_ENV='prod'`
3. `export JEST_CXG_PORT='5000'`
4. Run `npm run --prefix client/ smoke-test`
Run end to end tests interactively during development
1. cellxgene should be installed as [specified in client dev](#install-1)
2. Follow [launch](#launch-1) instructions for client dev with dataset `example-dataset/pbmc3k`
3. Run `npm run --prefix client/ e2e`
4. To debug a failing test `export JEST_ENV='debug'` and re-run.
+44 -15
View File
@@ -43,24 +43,53 @@ Follow these steps to create a release.
8. Publish to pypi by performing the following steps (assumes you that you have registered for pypi, 8. Publish to pypi by performing the following steps (assumes you that you have registered for pypi,
and that you have write access to the cellxgene pypi package): and that you have write access to the cellxgene pypi package):
- Build the distribution and upload to test pypi `make release-stage-2` - Build the distribution and upload to test pypi `make release-stage-2`
- [optional] Test the test installation in a fresh virtual environment using `make install-release-test` - Test the test installation in a fresh virtual environment using `make install-release-test`
- Upload the package to real pypi using `make release-stage-final` - Upload the package to real pypi using `make release-stage-final`
- [optional] Test the installation in a fresh virtual environment using - Test the installation in a fresh virtual environment using
`pip install cellxgene` `pip install cellxgene`
- **Troubleshooting**:
- Fails to upload to test.pypi: pypi doesn't allow you to reupload a release with the same version number,
if you accidentally burned a release number you want to use on prod, you have a couple options.
1) OPTION 1: Create distribution `make pydist`; test release locally `pip install dist/<release tarball>`;
then upload to prod `make release-stage-final`.
2) OPTION 2: (DANGER) release directly to prod: `make release-burned`.
3) OPTION 3: If the release was burned on prod as well run from Step 3 again with option
PART=patch until you get to an unburned version.
- The release doesn't install or fails your tests when you install it: Delete it from pypi - Go to pypi.org, sign in,
go to the cellxgene package, click manage, then in the options drop down, click delete and
follow the instructions. You will not be able to use that release number again. If it is a minor bug
and not a major regression, you can just release a patch.
The optional steps are for testing purposes, and are recommended The optional steps are for testing purposes, and are recommended
for publishing any major releases, and any releases that significantly for publishing any major releases, and any releases that significantly
change the packaging (e.g. new bundled files, new dependencies, etc.) change the packaging (e.g. new bundled files, new dependencies, etc.)
## Troubleshooting
### Fails to upload to test.pypi
_PyPi doesn't allow you to reupload a release with the same version number_
If you accidentally burned a release number you want to use on prod, you have a few options:
1) OPTION 1: Create distribution `make pydist`; test release locally `pip install dist/<release tarball>`;
then upload to prod `make release-stage-final`.
2) OPTION 2: (DANGER) release directly to prod: `make release-directly-to-prod`.
3) OPTION 3: If the release was burned on prod as well run from Step 3 again with option
PART=patch until you get to an unburned version.
### The release doesn't install or fails your tests when you install it
Delete it from pypi - Go to pypi.org -> sign in -> go to the cellxgene package -> click manage -> then in the options drop down click delete -> follow the instructions. You will not be able to use that release number again. If it is a minor bug and not a major regression, you can just release a patch.
### If you need to run stage final on a different computer than stage 2
If you run stage final without running stage 2 first, the dist will not have been build on the computer running stage final. The solution is to run `make release-directly-to-prod`. This both builds the distribution files and then releases directly to prod pypi.org.
## Stage Details
### Stage 1 - `make release-stage-1`
1. Pip installs requirements-dev
2. Bumps version by [PART]
3. Deletes build directory, client/build, dist and cellxgene.egg-info
4. Creates the package-lock.json
### Stage 2 - `make release-stage-2`
1. Pip installs requirements-dev
2. Builds client and server
3. Creates distribution release (sdist)
4. Uploads to test.pypi.org
### Stage final - `make release-stage-final`
** Does not build distribution **
1. Uploads to pypi.org
### (DANGER) Release directly to prod `make release-directly-to-prod`
** builds distribution and uploads directly to prod **
Only use this if you are directed to by the troubleshooting guide
1. Pip installs requirements-dev
2. Builds client and server
3. Creates distribution release (sdist)
4. Uploads to pypi.org
+1 -1
View File
@@ -38,7 +38,7 @@ Currently this is not supported directly, but you should be able to do this your
- `.obs` and `.var` annotations are use to extract metadata for filtering - `.obs` and `.var` annotations are use to extract metadata for filtering
- `.X` is used to display expression (histograms, scatterplot & colorscale) and to compute differential expression - `.X` is used to display expression (histograms, scatterplot & colorscale) and to compute differential expression
- `.obsm` is used for layout - `.obsm` is used for layout. If an embedding has more than two components, the first two will be used for visualization.
#### I have a BIG dataset - how can I make cellxgene run as fast as possible? #### I have a BIG dataset - how can I make cellxgene run as fast as possible?
+7 -3
View File
@@ -76,7 +76,7 @@ release-stage-final: twine-prod
# DANGER: releases directly to prod # DANGER: releases directly to prod
# use this if you accidently burned a test release version number, # use this if you accidently burned a test release version number,
release-burned : dev-env pydist twine-prod release-directly-to-prod : dev-env pydist twine-prod
@echo "Dist built and uploaded to pypi.org" @echo "Dist built and uploaded to pypi.org"
@echo "Test the install:" @echo "Test the install:"
@echo " make install-release" @echo " make install-release"
@@ -114,14 +114,18 @@ install-dev : uninstall
# install from test.pypi to test your release # install from test.pypi to test your release
install-release-test : uninstall install-release-test : uninstall
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene pip install --no-cache-dir --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene
@echo "Installed cellxgene from test.pypi.org, now run and smoke test" @echo "Installed cellxgene from test.pypi.org, now run and smoke test"
# install from pypi to test your release # install from pypi to test your release
install-release : uninstall install-release : uninstall
pip install cellxgene pip install --no-cache-dir cellxgene
@echo "Installed cellxgene from pypi.org" @echo "Installed cellxgene from pypi.org"
# install from dist
install-dist : uninstall
pip install dist/cellxgene*.tar.gz
uninstall : uninstall :
pip uninstall -y cellxgene || : pip uninstall -y cellxgene || :
+142 -63
View File
@@ -1,12 +1,13 @@
import warnings import warnings
import numpy as np import numpy as np
import pandas
from pandas.core.dtypes.dtypes import CategoricalDtype from pandas.core.dtypes.dtypes import CategoricalDtype
import scanpy as sc import anndata
from scipy import sparse from scipy import sparse
from server.app.driver.driver import CXGDriver from server.app.driver.driver import CXGDriver
from server.app.util.constants import Axis, DEFAULT_TOP_N from server.app.util.constants import Axis, DEFAULT_TOP_N, MAX_LAYOUTS
from server.app.util.errors import ( from server.app.util.errors import (
FilterError, FilterError,
JSONEncodingValueError, JSONEncodingValueError,
@@ -41,7 +42,7 @@ class ScanpyEngine(CXGDriver):
@staticmethod @staticmethod
def _get_default_config(): def _get_default_config():
return { return {
"layout": "umap", "layout": [],
"diffexp": "ttest", "diffexp": "ttest",
"max_category_items": 100, "max_category_items": 100,
"obs_names": None, "obs_names": None,
@@ -49,41 +50,61 @@ class ScanpyEngine(CXGDriver):
"diffexp_lfc_cutoff": 0.01, "diffexp_lfc_cutoff": 0.01,
} }
def _alias_annotation_names(self, axis, name): @staticmethod
""" def _create_unique_column_name(df, col_name_prefix):
Do all user-specified annotation aliasing. """ given the columns of a dataframe, and a name prefix, return a column name which
does not exist in the dataframe, AND which is prefixed by `prefix`
As a *critical* side-effect, ensure the indices are simple number ranges The approach is to append a numeric suffix, starting at zero and increasing by
(accomplished by calling pandas.DataFrame.reset_index()) one, until an unused name is found (eg, prefix_0, prefix_1, ...).
""" """
if name == "name": suffix = 0
# a noop, so skip it while f"{col_name_prefix}{suffix}" in df:
return suffix += 1
return f"{col_name_prefix}{suffix}"
ax_name = str(axis) def _alias_annotation_names(self):
df_axis = getattr(self.data, ax_name) """
if name is None: The front-end relies on the existance of a unique, human-readable
# reset index to simple range; alias "name" to point at the index for obs & var (eg, var is typically gene name, obs the cell name).
# previously specified index. The user can specify these via the --obs-names and --var-names config.
df_axis.reset_index(inplace=True) If they are not specified, use the existing index to create them, giving
df_axis.rename(inplace=True, columns={"index": "name"}) the resulting column a unique name (eg, "name").
elif name in df_axis.columns:
if name not in df_axis.columns: In both cases, enforce that the result is unique, and communicate the
index column name to the front-end via the obs_names and var_names config
(which is incorporated into the schema).
"""
for (ax_name, config_name) in ((Axis.OBS, "obs_names"), (Axis.VAR, "var_names")):
name = self.config[config_name]
df_axis = getattr(self.data, str(ax_name))
if name is None:
# Default: create unique names from index
if not df_axis.index.is_unique:
raise KeyError(
f"Values in {ax_name}.index must be unique. "
"Please prepare data to contain unique index values, or specify an "
"alternative with --{ax_name}-name."
)
name = self._create_unique_column_name(df_axis.columns, "name_")
self.config[config_name] = name
# reset index to simple range; alias name to point at the
# previously specified index.
df_axis.rename_axis(name, inplace=True)
df_axis.reset_index(inplace=True)
elif name in df_axis.columns:
# User has specified alternative column for unique names, and it exists
if not df_axis[name].is_unique:
raise KeyError(
f"Values in {ax_name}.{name} must be unique. "
"Please prepare data to contain unique values."
)
df_axis.reset_index(drop=True, inplace=True)
else:
# user specified a non-existent column name
raise KeyError( raise KeyError(
f"Annotation name {name}, specified in --{ax_name}-name does not exist." f"Annotation name {name}, specified in --{ax_name}-name does not exist."
) )
if not df_axis[name].is_unique:
raise KeyError(
f"Values in -{ax_name}-name must be unique. "
"Please prepare data to contain unique values."
)
# reset index to simple range; alias user-specified annotation to "name"
df_axis.reset_index(drop=True, inplace=True)
df_axis.rename(inplace=True, columns={name: "name"})
else:
raise KeyError(
f"Annotation name {name}, specified in --{ax_name}_name does not exist."
)
@staticmethod @staticmethod
def _can_cast_to_float32(ann): def _can_cast_to_float32(ann):
@@ -113,7 +134,17 @@ class ScanpyEngine(CXGDriver):
"nVar": self.gene_count, "nVar": self.gene_count,
"type": str(self.data.X.dtype), "type": str(self.data.X.dtype),
}, },
"annotations": {"obs": [], "var": []}, "annotations": {
"obs": {
"index": self.config["obs_names"],
"columns": []
},
"var": {
"index": self.config["var_names"],
"columns": []
}
},
"layout": {"obs": []}
} }
for ax in Axis: for ax in Axis:
curr_axis = getattr(self.data, str(ax)) curr_axis = getattr(self.data, str(ax))
@@ -137,14 +168,21 @@ class ScanpyEngine(CXGDriver):
raise TypeError( raise TypeError(
f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene." f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene."
) )
self.schema["annotations"][ax].append(ann_schema) self.schema["annotations"][ax]["columns"].append(ann_schema)
for layout in self.config['layout']:
layout_schema = {
"name": layout,
"type": "float32",
"dims": [f"{layout}_0", f"{layout}_1"]
}
self.schema["layout"]["obs"].append(layout_schema)
def _load_data(self, data): def _load_data(self, data):
# Based on benchmarking, cache=True has no impact on perf. # as of AnnData 0.6.19, backed mode performs initial load fast, but at the
# Note: as of current scanpy/anndata release, setting backed='r' will # cost of significantly slower access to X data.
# result in an error. https://github.com/theislab/anndata/issues/79
try: try:
self.data = sc.read(data, cache=True) self.data = anndata.read_h5ad(data)
except ValueError: except ValueError:
raise ScanpyFileError( raise ScanpyFileError(
"File must be in the .h5ad format. Please read " "File must be in the .h5ad format. Please read "
@@ -164,16 +202,68 @@ class ScanpyEngine(CXGDriver):
@requires_data @requires_data
def _validate_and_initialize(self): def _validate_and_initialize(self):
self._alias_annotation_names(Axis.OBS, self.config["obs_names"]) # var and obs column names must be unique
self._alias_annotation_names(Axis.VAR, self.config["var_names"]) if not self.data.obs.columns.is_unique or not self.data.var.columns.is_unique:
raise KeyError(f"All annotation column names must be unique.")
self._alias_annotation_names()
self._validate_data_types() self._validate_data_types()
self._validate_data_calculations()
self.cell_count = self.data.shape[0] self.cell_count = self.data.shape[0]
self.gene_count = self.data.shape[1] self.gene_count = self.data.shape[1]
self._default_and_validate_layouts()
self._create_schema() self._create_schema()
@requires_data
def _default_and_validate_layouts(self):
""" function:
a) generate list of default layouts, if not already user specified
b) validate layouts are legal. remove/warn on any that are not
c) cap total list of layouts at global const MAX_LAYOUTS
"""
layouts = self.config['layout']
# handle default
if layouts is None or len(layouts) == 0:
# load default layouts from the data.
layouts = [key[2:] for key in self.data.obsm_keys() if type(key) == str and key.startswith("X_")]
if len(layouts) == 0:
raise PrepareError(f"Unable to find any precomputed layouts within the dataset.")
# remove invalid layouts
valid_layouts = []
obsm_keys = self.data.obsm_keys()
for layout in layouts:
layout_name = f"X_{layout}"
if layout_name not in obsm_keys:
warnings.warn(f"Ignoring unknown layout name: {layout}.")
elif not self._is_valid_layout(self.data.obsm[layout_name]):
warnings.warn(f"Ignoring layout due to malformed shape or data type: {layout}")
else:
valid_layouts.append(layout)
if len(valid_layouts) == 0:
raise PrepareError(f"No valid layout data.")
# cap layouts to MAX_LAYOUTS
self.config['layout'] = valid_layouts[0:MAX_LAYOUTS]
@requires_data
def _is_valid_layout(self, arr):
""" return True if this layout data is a valid array for front-end presentation:
* ndarray, with shape (n_obs, >= 2), dtype float/int/uint
* contains only finite values
"""
is_valid = type(arr) == np.ndarray and arr.dtype.kind in "fiu"
is_valid = is_valid and arr.shape[0] == self.data.n_obs and arr.shape[1] >= 2
is_valid = is_valid and np.all(np.isfinite(arr))
return is_valid
@requires_data @requires_data
def _validate_data_types(self): def _validate_data_types(self):
if sparse.isspmatrix(self.data.X) and not sparse.isspmatrix_csc(self.data.X):
warnings.warn(
f"Scanpy data matrix is sparse, but not a CSC (columnar) matrix. "
f"Performance may be improved by using CSC."
)
if self.data.X.dtype != "float32": if self.data.X.dtype != "float32":
warnings.warn( warnings.warn(
f"Scanpy data matrix is in {self.data.X.dtype} format not float32. " f"Scanpy data matrix is in {self.data.X.dtype} format not float32. "
@@ -204,20 +294,6 @@ class ScanpyEngine(CXGDriver):
f"annotations with more than 500 categories in the UI" f"annotations with more than 500 categories in the UI"
) )
@requires_data
def _validate_data_calculations(self):
layout_key = f"X_{self.config['layout']}"
try:
assert layout_key in self.data.obsm_keys()
except AssertionError:
raise PrepareError(
f"Cannot find a field with coordinates for the {self.config['layout']} layout requested. A different"
f" layout may have been computed. The requested layout must be pre-calculated and saved "
f"back in the h5ad file. You can run "
f"`cellxgene prepare --layout {self.config['layout']} <datafile>` "
f"to solve this problem. "
)
@staticmethod @staticmethod
def _annotation_filter_to_mask(filter, d_axis, count): def _annotation_filter_to_mask(filter, d_axis, count):
mask = np.ones((count,), dtype=bool) mask = np.ones((count,), dtype=bool)
@@ -304,7 +380,7 @@ class ScanpyEngine(CXGDriver):
if sparse.issparse(X): # use tuned getcol/hstack for performance if sparse.issparse(X): # use tuned getcol/hstack for performance
indices = np.nonzero(var_mask)[0] indices = np.nonzero(var_mask)[0]
cols = [X.getcol(i) for i in indices] cols = [X.getcol(i) for i in indices]
return sparse.hstack(cols) return sparse.hstack(cols, format="csc")
else: # else, just use standard slicing, which is fine for dense arrays else: # else, just use standard slicing, which is fine for dense arrays
return X[:, var_mask] return X[:, var_mask]
@@ -368,15 +444,18 @@ class ScanpyEngine(CXGDriver):
* only returns Matrix in columnar layout * only returns Matrix in columnar layout
""" """
try: try:
full_embedding = self.data.obsm[f"X_{self.config['layout']}"] layout_data = []
if full_embedding.shape[1] > 2: for layout in self.config["layout"]:
warnings.warn(f"Warning: found {full_embedding.shape[1]} \ full_embedding = self.data.obsm[f"X_{layout}"]
components of embedding. Using the first two for layout display.") embedding = full_embedding[:, :2]
df_layout = full_embedding[:, :2] normalized_layout = (embedding - embedding.min()) / (embedding.max() - embedding.min())
normalized_layout = normalized_layout.astype(dtype=np.float32)
layout_data.append(pandas.DataFrame(normalized_layout, columns=[f"{layout}_0", f"{layout}_1"]))
except ValueError as e: except ValueError as e:
raise PrepareError( raise PrepareError(
f"Layout has not been calculated using {self.config['layout']}, " f"Layout has not been calculated using {self.config['layout']}, "
f"please prepare your datafile and relaunch cellxgene") from e f"please prepare your datafile and relaunch cellxgene") from e
normalized_layout = (df_layout - df_layout.min()) / (df_layout.max() - df_layout.min()) df = pandas.concat(layout_data, axis=1, copy=False)
return encode_matrix_fbs(normalized_layout.astype(dtype=np.float32), col_idx=None, row_idx=None) return encode_matrix_fbs(df, col_idx=df.columns, row_idx=None)
+2
View File
@@ -31,3 +31,5 @@ JSON_NaN_to_num_warning_msg = (
"JSON encoding failure - please verify all data are finite values (no NaN or Infinities)" "JSON encoding failure - please verify all data are finite values (no NaN or Infinities)"
) )
REACTIVE_LIMIT = 1_000_000 REACTIVE_LIMIT = 1_000_000
MAX_LAYOUTS = 30
+1 -1
View File
@@ -5,7 +5,7 @@ from .prepare import prepare
@click.group(name="cellxgene", context_settings=dict(max_content_width=85)) @click.group(name="cellxgene", context_settings=dict(max_content_width=85))
@click.version_option(version="0.9.1", prog_name="cellxgene", message="[%(prog)s] Version %(version)s") @click.version_option(version="0.10.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
def cli(): def cli():
pass pass
+35 -8
View File
@@ -1,6 +1,7 @@
import errno
import logging import logging
from os import devnull from os import devnull
from os.path import splitext, basename from os.path import splitext, basename, getsize
import sys import sys
import warnings import warnings
import webbrowser import webbrowser
@@ -10,7 +11,11 @@ import click
from server.app.app import Server from server.app.app import Server
from server.app.util.errors import ScanpyFileError from server.app.util.errors import ScanpyFileError
from server.app.util.utils import custom_format_warning from server.app.util.utils import custom_format_warning
from server.utils.constants import MODES from server.utils.utils import find_available_port, is_port_available
# anything bigger than this will generate a special message
BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB
@click.command() @click.command()
@@ -18,10 +23,10 @@ from server.utils.constants import MODES
@click.option( @click.option(
"--layout", "--layout",
"-l", "-l",
type=click.Choice(MODES), default=[],
default="umap", multiple=True,
show_default=True, show_default=True,
help="Method for layout." help="Layout name, eg, 'umap'."
) )
@click.option( @click.option(
"--diffexp", "--diffexp",
@@ -50,7 +55,8 @@ from server.utils.constants import MODES
show_default=True, show_default=True,
help="Open the web browser after launch.", help="Open the web browser after launch.",
) )
@click.option("--port", "-p", help="Port to run server on.", metavar="", default=5005, show_default=True) @click.option("--port", "-p", help="Port to run server on, if not specified cellxgene will find an available port.",
metavar="", show_default=True)
@click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.") @click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.")
@click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.") @click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.")
@click.option("--host", default="127.0.0.1", help="Host IP address") @click.option("--host", default="127.0.0.1", help="Host IP address")
@@ -135,6 +141,16 @@ security risk by including the --scripts flag. Make sure you trust the scripts t
file_parts = splitext(basename(data)) file_parts = splitext(basename(data))
title = file_parts[0] title = file_parts[0]
if port:
if debug:
raise click.ClickException("--port and --debug may not be used together (try --verbose for error logging).")
if not is_port_available(host, int(port)):
raise click.ClickException(
f"The port selected {port} is in use, please specify an open port using the --port flag."
)
else:
port = find_available_port(host)
# Setup app # Setup app
cellxgene_url = f"http://{host}:{port}" cellxgene_url = f"http://{host}:{port}"
@@ -148,7 +164,13 @@ security risk by including the --scripts flag. Make sure you trust the scripts t
log = logging.getLogger("werkzeug") log = logging.getLogger("werkzeug")
log.setLevel(logging.ERROR) log.setLevel(logging.ERROR)
click.echo(f"[cellxgene] Loading data from {basename(data)}, this may take awhile...") file_size = getsize(data)
# if a big file, let the user know it may take a while to load.
if file_size > BIG_FILE_SIZE_THRESHOLD:
click.echo(f"[cellxgene] Loading data from {basename(data)}, this may take awhile...")
else:
click.echo(f"[cellxgene] Loading data from {basename(data)}.")
# Fix for anaconda python. matplotlib typically expects python to be installed as a framework TKAgg is usually # Fix for anaconda python. matplotlib typically expects python to be installed as a framework TKAgg is usually
# available and fixes this issue. See https://matplotlib.org/faq/virtualenv_faq.html # available and fixes this issue. See https://matplotlib.org/faq/virtualenv_faq.html
@@ -183,4 +205,9 @@ security risk by including the --scripts flag. Make sure you trust the scripts t
f = open(devnull, "w") f = open(devnull, "w")
sys.stdout = f sys.stdout = f
server.app.run(host=host, debug=debug, port=port, threaded=True) try:
server.app.run(host=host, debug=debug, port=port, threaded=True, use_debugger=False)
except OSError as e:
if e.errno == errno.EADDRINUSE:
raise click.ClickException("Port is in use, please specify an open port using the --port flag.") from e
raise
+1
View File
@@ -1,3 +1,4 @@
anndata>=0.6.20
click>=6.7 click>=6.7
Flask>=1.0.2 Flask>=1.0.2
Flask-Caching>=1.4.0 Flask-Caching>=1.4.0
+53 -38
View File
@@ -5,46 +5,61 @@
"type": "float32" "type": "float32"
}, },
"annotations": { "annotations": {
"obs": {
"index": "name_0",
"columns": [
{
"name": "name_0",
"type": "string"
},
{
"name": "n_genes",
"type": "int32"
},
{
"name": "percent_mito",
"type": "float32"
},
{
"name": "n_counts",
"type": "float32"
},
{
"name": "louvain",
"type": "categorical",
"categories": [
"CD4 T cells",
"CD14+ Monocytes",
"B cells",
"CD8 T cells",
"NK cells",
"FCGR3A+ Monocytes",
"Dendritic cells",
"Megakaryocytes"
]
}
]
},
"var": {
"index": "name_0",
"columns": [
{
"name": "name_0",
"type": "string"
},
{
"name": "n_cells",
"type": "int32"
}
]
}
},
"layout": {
"obs": [ "obs": [
{ {
"name": "name", "name": "umap",
"type": "string" "type": "float32",
}, "dims": ["umap_0", "umap_1"]
{
"name": "n_genes",
"type": "int32"
},
{
"name": "percent_mito",
"type": "float32"
},
{
"name": "n_counts",
"type": "float32"
},
{
"name": "louvain",
"type": "categorical",
"categories": [
"CD4 T cells",
"CD14+ Monocytes",
"B cells",
"CD8 T cells",
"NK cells",
"FCGR3A+ Monocytes",
"Dendritic cells",
"Megakaryocytes"
]
}
],
"var": [
{
"name": "name",
"type": "string"
},
{
"name": "n_cells",
"type": "int32"
} }
] ]
} }
+15 -8
View File
@@ -19,11 +19,12 @@ class EndPoints(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.ps = Popen(["cellxgene", "launch", "example-dataset/pbmc3k.h5ad", "--debug"]) cls.ps = Popen(["cellxgene", "launch", "example-dataset/pbmc3k.h5ad", "--verbose", "--port", "5005"])
session = requests.Session() session = requests.Session()
for i in range(90): for i in range(90):
try: try:
session.get(f"{URL_BASE}schema") result = session.get(f"{URL_BASE}schema")
cls.schema = result.json()
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
time.sleep(1) time.sleep(1)
@@ -45,7 +46,8 @@ class EndPoints(unittest.TestCase):
self.assertEqual(result.headers["Content-Type"], "application/json") self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json() result_data = result.json()
self.assertEqual(result_data["schema"]["dataframe"]["nObs"], 2638) self.assertEqual(result_data["schema"]["dataframe"]["nObs"], 2638)
self.assertEqual(len(result_data["schema"]["annotations"]["obs"]), 5) self.assertEqual(len(result_data["schema"]["annotations"]["obs"]), 2)
self.assertEqual(len(result_data["schema"]["annotations"]["obs"]["columns"]), 5)
def test_config(self): def test_config(self):
endpoint = "config" endpoint = "config"
@@ -67,9 +69,11 @@ class EndPoints(unittest.TestCase):
self.assertEqual(result.headers["Content-Type"], "application/octet-stream") self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content) df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df['n_rows'], 2638) self.assertEqual(df['n_rows'], 2638)
self.assertEqual(df['n_cols'], 2) self.assertEqual(df['n_cols'], 8)
self.assertIsNotNone(df['columns']) self.assertIsNotNone(df['columns'])
self.assertIsNone(df['col_idx']) self.assertListEqual(df['col_idx'], [
'pca_0', 'pca_1', 'tsne_0', 'tsne_1', 'umap_0', 'umap_1', 'draw_graph_fr_0', 'draw_graph_fr_1'
])
self.assertIsNone(df['row_idx']) self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
@@ -93,7 +97,8 @@ class EndPoints(unittest.TestCase):
self.assertIsNotNone(df['col_idx']) self.assertIsNotNone(df['col_idx'])
self.assertIsNone(df['row_idx']) self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['name', 'n_genes', 'percent_mito', 'n_counts', 'louvain']) obs_index_col_name = self.schema["schema"]["annotations"]["obs"]["index"]
self.assertListEqual(df['col_idx'], [obs_index_col_name, 'n_genes', 'percent_mito', 'n_counts', 'louvain'])
def test_get_annotations_obs_keys_fbs(self): def test_get_annotations_obs_keys_fbs(self):
endpoint = "annotations/obs" endpoint = "annotations/obs"
@@ -163,7 +168,8 @@ class EndPoints(unittest.TestCase):
self.assertIsNotNone(df['col_idx']) self.assertIsNotNone(df['col_idx'])
self.assertIsNone(df['row_idx']) self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['name', 'n_cells']) var_index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
self.assertListEqual(df['col_idx'], [var_index_col_name, 'n_cells'])
def test_get_annotations_var_keys_fbs(self): def test_get_annotations_var_keys_fbs(self):
endpoint = "annotations/var" endpoint = "annotations/var"
@@ -245,7 +251,8 @@ class EndPoints(unittest.TestCase):
endpoint = f"data/var" endpoint = f"data/var"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
var_filter = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}}} index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
var_filter = {"filter": {"var": {"annotation_value": [{"name": index_col_name, "values": ["RER1"]}]}}}
result = self.session.put(url, headers=header, json=var_filter) result = self.session.put(url, headers=header, json=var_filter)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream") self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
+2 -2
View File
@@ -8,7 +8,7 @@ import decode_fbs
import requests import requests
LOCAL_URL = "http://127.0.0.1:5005/" LOCAL_URL = "http://127.0.0.1:5006/"
VERSION = "v0.2" VERSION = "v0.2"
URL_BASE = f"{LOCAL_URL}api/{VERSION}/" URL_BASE = f"{LOCAL_URL}api/{VERSION}/"
@@ -21,7 +21,7 @@ class WithNaNs(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.ps = Popen( cls.ps = Popen(
["cellxgene", "launch", "server/test/test_datasets/nan.h5ad", "--debug"] ["cellxgene", "launch", "server/test/test_datasets/nan.h5ad", "--verbose", "--port", "5006"]
) )
session = requests.Session() session = requests.Session()
for i in range(90): for i in range(90):
+5 -3
View File
@@ -12,7 +12,7 @@ from server.app.util.errors import FilterError
class NaNTest(unittest.TestCase): class NaNTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.args = { self.args = {
"layout": "umap", "layout": ["umap"],
"diffexp": "ttest", "diffexp": "ttest",
"max_category_items": 100, "max_category_items": 100,
"obs_names": None, "obs_names": None,
@@ -58,14 +58,16 @@ class NaNTest(unittest.TestCase):
def test_annotation(self): def test_annotation(self):
annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("obs")) annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("obs"))
obs_index_col_name = self.data.schema["annotations"]["obs"]["index"]
self.assertEqual( self.assertEqual(
annotations["col_idx"], annotations["col_idx"],
["name", "n_genes", "percent_mito", "n_counts", "louvain"] [obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain"]
) )
self.assertEqual(annotations["n_rows"], 100) self.assertEqual(annotations["n_rows"], 100)
self.assertTrue(math.isnan(annotations["columns"][2][0])) self.assertTrue(math.isnan(annotations["columns"][2][0]))
annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("var")) annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("var"))
self.assertEqual(annotations["col_idx"], ["name", "n_cells", "var_with_nans"]) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
self.assertEqual(annotations["col_idx"], [var_index_col_name, "n_cells", "var_with_nans"])
self.assertEqual(annotations["n_rows"], 100) self.assertEqual(annotations["n_rows"], 100)
self.assertTrue(math.isnan(annotations["columns"][2][0])) self.assertTrue(math.isnan(annotations["columns"][2][0]))
+18 -10
View File
@@ -15,7 +15,7 @@ from server.app.util.errors import FilterError
class EngineTest(unittest.TestCase): class EngineTest(unittest.TestCase):
def setUp(self): def setUp(self):
args = { args = {
"layout": "umap", "layout": ["umap"],
"diffexp": "ttest", "diffexp": "ttest",
"max_category_items": 100, "max_category_items": 100,
"obs_names": None, "obs_names": None,
@@ -31,9 +31,11 @@ class EngineTest(unittest.TestCase):
self.assertTrue(self.data.data.X[0, 0] - -0.171_469_51 < epsilon) self.assertTrue(self.data.data.X[0, 0] - -0.171_469_51 < epsilon)
def test_mandatory_annotations(self): def test_mandatory_annotations(self):
self.assertIn("name", self.data.data.obs) obs_index_col_name = self.data.schema["annotations"]["obs"]["index"]
self.assertIn(obs_index_col_name, self.data.data.obs)
self.assertEqual(list(self.data.data.obs.index), list(range(2638))) self.assertEqual(list(self.data.data.obs.index), list(range(2638)))
self.assertIn("name", self.data.data.var) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
self.assertIn(var_index_col_name, self.data.data.var)
self.assertEqual(list(self.data.data.var.index), list(range(1838))) self.assertEqual(list(self.data.data.var.index), list(range(1838)))
@pytest.mark.filterwarnings("ignore:Scanpy data matrix") @pytest.mark.filterwarnings("ignore:Scanpy data matrix")
@@ -70,12 +72,14 @@ class EngineTest(unittest.TestCase):
self.assertEqual(data["n_cols"], 91) self.assertEqual(data["n_cols"], 91)
def test_obs_and_var_names(self): def test_obs_and_var_names(self):
self.assertEqual(np.sum(self.data.data.var["name"].isna()), 0) self.assertEqual(np.sum(self.data.data.var[self.data.schema["annotations"]["var"]["index"]].isna()), 0)
self.assertEqual(np.sum(self.data.data.obs["name"].isna()), 0) self.assertEqual(np.sum(self.data.data.obs[self.data.schema["annotations"]["obs"]["index"]].isna()), 0)
def test_schema(self): def test_schema(self):
with open(path.join(path.dirname(__file__), "schema.json")) as fh: with open(path.join(path.dirname(__file__), "schema.json")) as fh:
schema = json.load(fh) schema = json.load(fh)
print(schema)
print(self.data.schema)
self.assertEqual(self.data.schema, schema) self.assertEqual(self.data.schema, schema)
def test_schema_produces_error(self): def test_schema_produces_error(self):
@@ -108,16 +112,18 @@ class EngineTest(unittest.TestCase):
annotations = decode_fbs.decode_matrix_FBS(fbs) annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(annotations["n_rows"], 2638) self.assertEqual(annotations["n_rows"], 2638)
self.assertEqual(annotations["n_cols"], 5) self.assertEqual(annotations["n_cols"], 5)
obs_index_col_name = self.data.schema["annotations"]["obs"]["index"]
self.assertEqual( self.assertEqual(
annotations["col_idx"], annotations["col_idx"],
["name", "n_genes", "percent_mito", "n_counts", "louvain"], [obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain"],
) )
fbs = self.data.annotation_to_fbs_matrix("var") fbs = self.data.annotation_to_fbs_matrix("var")
annotations = decode_fbs.decode_matrix_FBS(fbs) annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(annotations['n_rows'], 1838) self.assertEqual(annotations['n_rows'], 1838)
self.assertEqual(annotations['n_cols'], 2) self.assertEqual(annotations['n_cols'], 2)
self.assertEqual(annotations["col_idx"], ["name", "n_cells"]) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
self.assertEqual(annotations["col_idx"], [var_index_col_name, "n_cells"])
def test_annotation_fields(self): def test_annotation_fields(self):
fbs = self.data.annotation_to_fbs_matrix("obs", ["n_genes", "n_counts"]) fbs = self.data.annotation_to_fbs_matrix("obs", ["n_genes", "n_counts"])
@@ -125,7 +131,8 @@ class EngineTest(unittest.TestCase):
self.assertEqual(annotations["n_rows"], 2638) self.assertEqual(annotations["n_rows"], 2638)
self.assertEqual(annotations['n_cols'], 2) self.assertEqual(annotations['n_cols'], 2)
fbs = self.data.annotation_to_fbs_matrix("var", ["name"]) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
fbs = self.data.annotation_to_fbs_matrix("var", [var_index_col_name])
annotations = decode_fbs.decode_matrix_FBS(fbs) annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(annotations['n_rows'], 1838) self.assertEqual(annotations['n_rows'], 1838)
self.assertEqual(annotations['n_cols'], 1) self.assertEqual(annotations['n_cols'], 1)
@@ -163,9 +170,10 @@ class EngineTest(unittest.TestCase):
self.data.data_frame_to_fbs_matrix(filter_["filter"], "var") self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
def test_data_named_gene(self): def test_data_named_gene(self):
var_index_col_name = self.data.schema["annotations"]["var"]["index"]
filter_ = { filter_ = {
"filter": { "filter": {
"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]} "var": {"annotation_value": [{"name": var_index_col_name, "values": ["RER1"]}]}
} }
} }
fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var") fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
@@ -176,7 +184,7 @@ class EngineTest(unittest.TestCase):
filter_ = { filter_ = {
"filter": { "filter": {
"var": {"annotation_value": [{"name": "name", "values": ["SPEN", "TYMP", "PRMT2"]}]} "var": {"annotation_value": [{"name": var_index_col_name, "values": ["SPEN", "TYMP", "PRMT2"]}]}
} }
} }
fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var") fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
+1 -1
View File
@@ -15,7 +15,7 @@ class DataLoadEngineTest(unittest.TestCase):
def test_delayed_load_args(self): def test_delayed_load_args(self):
args = { args = {
"layout": "tsne", "layout": ["tsne"],
"diffexp": "ttest", "diffexp": "ttest",
"max_category_items": 1000, "max_category_items": 1000,
"obs_names": "foo", "obs_names": "foo",
+26
View File
@@ -0,0 +1,26 @@
import contextlib
import errno
import socket
def find_available_port(host, port=5005):
"""
Helper method to find open port on host. Tries 5000 ports incremented from the specified port
"""
# Takes approx 2 seconds to do a scan of 5000 ports on my laptop
num_ports_to_try = 5000
for port_to_try in range(port, port + num_ports_to_try):
if is_port_available(host, port_to_try):
return port_to_try
raise socket.error(errno.EADDRINUSE, f"No port in range {port} - {port + num_ports_to_try - 1} available.")
def is_port_available(host, port):
is_available = False
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
try:
s.bind((host, port))
is_available = True
except socket.error:
pass
return is_available
+1 -1
View File
@@ -8,7 +8,7 @@ with open("server/requirements.txt") as fh:
setup( setup(
name="cellxgene", name="cellxgene",
version="0.9.1", version="0.10.0",
packages=find_packages(), packages=find_packages(),
url="https://github.com/chanzuckerberg/cellxgene", url="https://github.com/chanzuckerberg/cellxgene",
license="MIT", license="MIT",