mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-28 04:58:12 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ad28a510 | ||
|
|
efa1709158 | ||
|
|
d6040f687a | ||
|
|
b9a1e30652 | ||
|
|
7adac5d004 | ||
|
|
2354731083 | ||
|
|
d522cc8f91 | ||
|
|
86eb01eb2c | ||
|
|
8a94b1e086 | ||
|
|
846b8d15bd |
+2
-2
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,38 @@ 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 () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -161,5 +176,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -162,29 +162,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 = {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Generated
+9
-9
@@ -11318,9 +11318,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"puppeteer": {
|
"puppeteer": {
|
||||||
"version": "1.12.2",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-1.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-1.15.0.tgz",
|
||||||
"integrity": "sha512-xWSyCeD6EazGlfnQweMpM+Hs6X6PhUYhNTHKFj/axNZDq4OmrVERf70isBf7HsnFgB3zOC1+23/8+wCAZYg+Pg==",
|
"integrity": "sha512-D2y5kwA9SsYkNUmcBzu9WZ4V1SGHiQTmgvDZSx6sRYFsgV25IebL4V6FaHjF6MbwLK9C6f3G3pmck9qmwM8H3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
@@ -11343,15 +11343,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mime": {
|
"mime": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.2.tgz",
|
||||||
"integrity": "sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w==",
|
"integrity": "sha512-zJBfZDkwRu+j3Pdd2aHsR5GfH2jIWhmL1ZzBoc+X+3JEti2hbArWcyJ+1laC1D2/U/W1a/+Cegj0/OnEU2ybjg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ws": {
|
"ws": {
|
||||||
"version": "6.1.4",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
|
||||||
"integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==",
|
"integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"async-limiter": "~1.0.0"
|
"async-limiter": "~1.0.0"
|
||||||
|
|||||||
+4
-2
@@ -104,7 +104,7 @@
|
|||||||
"jest-puppeteer": "^4.1.0",
|
"jest-puppeteer": "^4.1.0",
|
||||||
"json-loader": "^0.5.4",
|
"json-loader": "^0.5.4",
|
||||||
"mini-css-extract-plugin": "^0.4.1",
|
"mini-css-extract-plugin": "^0.4.1",
|
||||||
"puppeteer": "^1.12.1",
|
"puppeteer": "^1.15.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.7.11",
|
||||||
@@ -142,7 +142,9 @@
|
|||||||
],
|
],
|
||||||
"@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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -898,6 +898,7 @@ class Graph extends React.Component {
|
|||||||
target={
|
target={
|
||||||
<Button
|
<Button
|
||||||
type="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 +930,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 +951,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 +971,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={{
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -10,7 +9,7 @@ import DynamicScatterplot from "./scatterplot/scatterplot";
|
|||||||
|
|
||||||
@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
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,13 +99,30 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO: XXX
|
||||||
|
|
||||||
|
TEMPORARY CODE AND COMMENT to support the progressive implementation
|
||||||
|
of multi-layout support. For now, we search for one of the following
|
||||||
|
in the layouts and use it if we find it: umap, then tsne, then pca,
|
||||||
|
then whatever is first in the list.
|
||||||
|
*/
|
||||||
|
let layoutIndex = 0;
|
||||||
|
["umap", "tsne", "pca"].some(name => {
|
||||||
|
const idx = fbs.colIdx.indexOf(`${name}_0`);
|
||||||
|
if (idx !== -1) {
|
||||||
|
layoutIndex = idx;
|
||||||
|
}
|
||||||
|
return idx !== -1;
|
||||||
|
});
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[fbs.nRows, fbs.nCols],
|
[fbs.nRows, 2],
|
||||||
fbs.columns,
|
[fbs.columns[layoutIndex], fbs.columns[layoutIndex + 1]],
|
||||||
null,
|
null,
|
||||||
new Dataframe.KeyIndex(["X", "Y"])
|
new Dataframe.KeyIndex(["X", "Y"])
|
||||||
);
|
);
|
||||||
@@ -122,15 +140,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.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+44
-15
@@ -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
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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 || :
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -140,11 +141,10 @@ class ScanpyEngine(CXGDriver):
|
|||||||
self.schema["annotations"][ax].append(ann_schema)
|
self.schema["annotations"][ax].append(ann_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 "
|
||||||
@@ -167,13 +167,62 @@ class ScanpyEngine(CXGDriver):
|
|||||||
self._alias_annotation_names(Axis.OBS, self.config["obs_names"])
|
self._alias_annotation_names(Axis.OBS, self.config["obs_names"])
|
||||||
self._alias_annotation_names(Axis.VAR, self.config["var_names"])
|
self._alias_annotation_names(Axis.VAR, self.config["var_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 +253,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 +339,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 +403,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+32
-8
@@ -1,16 +1,22 @@
|
|||||||
|
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
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import psutil
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
# anything bigger than this will generate a special message
|
||||||
|
BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@@ -18,10 +24,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 +56,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 +142,9 @@ 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 not port:
|
||||||
|
port = find_available_port(host)
|
||||||
|
|
||||||
# Setup app
|
# Setup app
|
||||||
cellxgene_url = f"http://{host}:{port}"
|
cellxgene_url = f"http://{host}:{port}"
|
||||||
|
|
||||||
@@ -148,7 +158,16 @@ 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)}.")
|
||||||
|
# if file is larger than main memory, let the user know performance may suffer
|
||||||
|
if file_size > .95 * psutil.virtual_memory().total:
|
||||||
|
click.echo(f"[cellxgene] Warning: data file is larger than RAM - application may be very slow.")
|
||||||
|
|
||||||
# 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 +202,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)
|
||||||
|
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,3 +1,4 @@
|
|||||||
|
anndata>=0.6.15
|
||||||
click>=6.7
|
click>=6.7
|
||||||
Flask>=1.0.2
|
Flask>=1.0.2
|
||||||
Flask-Caching>=1.4.0
|
Flask-Caching>=1.4.0
|
||||||
@@ -8,7 +9,8 @@ flatbuffers>=1.10.0
|
|||||||
matplotlib>=2.2
|
matplotlib>=2.2
|
||||||
numpy>=1.15.2
|
numpy>=1.15.2
|
||||||
pandas>=0.23.1
|
pandas>=0.23.1
|
||||||
|
psutil>=5.6.2
|
||||||
scanpy>=1.3.7
|
scanpy>=1.3.7
|
||||||
scipy>=1.1.0
|
scipy>=1.1.0,<1.3
|
||||||
scikit-learn>=0.19.1,!=0.20.0
|
scikit-learn>=0.19.1,!=0.20.0
|
||||||
tables>=3.5.1
|
tables>=3.5.1
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ 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", "--debug", "--port", "5005"])
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
for i in range(90):
|
for i in range(90):
|
||||||
try:
|
try:
|
||||||
@@ -67,9 +67,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'])
|
||||||
|
|
||||||
|
|||||||
@@ -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", "--debug", "--port", "5005"]
|
||||||
)
|
)
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
for i in range(90):
|
for i in range(90):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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):
|
||||||
|
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||||
|
try:
|
||||||
|
s.bind((host, port_to_try))
|
||||||
|
return port_to_try
|
||||||
|
except socket.error:
|
||||||
|
pass
|
||||||
|
raise socket.error(errno.EADDRINUSE, f"No port in range {port} - {port + num_ports_to_try - 1} available.")
|
||||||
Reference in New Issue
Block a user