mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-28 06:48:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f5263c88 | ||
|
|
933fac5fff | ||
|
|
0c26f227fe | ||
|
|
2b90c747f5 | ||
|
|
43a4e087ef | ||
|
|
f2612707bb | ||
|
|
138d30909a |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.2.0
|
current_version = 0.2.3
|
||||||
|
|
||||||
[bumpversion:file:setup.py]
|
[bumpversion:file:setup.py]
|
||||||
search = version="{current_version}"
|
search = version="{current_version}"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
`cellxgene` is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization.
|
`cellxgene` is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization.
|
||||||
|
|
||||||
<img src="https://github.com/chanzuckerberg/cellxgene/blob/master/docs/cellxgene-demo-1.gif" width="200" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/blob/master/docs/cellxgene-demo-2.gif" width="200" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/blob/master/docs/cellxgene-demo-3.gif" width="200" height="200" hspace="30">
|
<img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-1.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-2.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-3.gif" width="200" height="200" hspace="30">
|
||||||
|
|
||||||
## getting started
|
## getting started
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ cellxgene launch pbmc3k.h5ad --open
|
|||||||
```
|
```
|
||||||
You should see your web browser open with the following
|
You should see your web browser open with the following
|
||||||
|
|
||||||
<img width="450" src="https://github.com/chanzuckerberg/cellxgene/blob/master/docs/cellxgene-opening-screenshot.png" pad="50px">
|
<img width="450" src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-opening-screenshot.png" pad="50px">
|
||||||
|
|
||||||
**Note**: automatic opening of the browser with the `--open` flag only works on OS X, on other platforms you'll need to directly point to the provided link in your browser.
|
**Note**: automatic opening of the browser with the `--open` flag only works on OS X, on other platforms you'll need to directly point to the provided link in your browser.
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cellxgene",
|
"name": "cellxgene",
|
||||||
"version": "0.1.0",
|
"version": "0.2.3",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cellxgene",
|
"name": "cellxgene",
|
||||||
"version": "0.2.0",
|
"version": "0.2.3",
|
||||||
"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",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class HistogramBrush extends React.Component {
|
|||||||
.scaleLinear()
|
.scaleLinear()
|
||||||
.range([this.height - this.marginBottom, 0]);
|
.range([this.height - this.marginBottom, 0]);
|
||||||
|
|
||||||
if (obsAnnotations[0][field]) {
|
if (obsAnnotations[0][field] !== undefined) {
|
||||||
// recalculate expensive stuff
|
// recalculate expensive stuff
|
||||||
const allValuesForContinuousFieldAsArray = _.map(obsAnnotations, field);
|
const allValuesForContinuousFieldAsArray = _.map(obsAnnotations, field);
|
||||||
|
|
||||||
|
|||||||
@@ -5,34 +5,13 @@ import { connect } from "react-redux";
|
|||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import Category from "./category";
|
import Category from "./category";
|
||||||
|
|
||||||
/* Cap the max number of displayed categories */
|
|
||||||
const truncateCategories = options => {
|
|
||||||
const numOptions = _.size(options);
|
|
||||||
if (numOptions <= globals.maxCategoricalOptionsToDisplay) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
return _(options)
|
|
||||||
.map((v, k) => ({ name: k, val: v }))
|
|
||||||
.sortBy("val")
|
|
||||||
.slice(numOptions - globals.maxCategoricalOptionsToDisplay)
|
|
||||||
.transform((r, v) => {
|
|
||||||
r[v.name] = v.val;
|
|
||||||
}, {})
|
|
||||||
.value();
|
|
||||||
};
|
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
ranges: _.get(state.controls.world, "summary.obs", null),
|
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||||
categorySelectionLimit: _.get(
|
|
||||||
state.config,
|
|
||||||
"parameters.max-category-items",
|
|
||||||
globals.configDefaults.parameters["max-category-items"]
|
|
||||||
)
|
|
||||||
}))
|
}))
|
||||||
class Categories extends React.Component {
|
class Categories extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
const { ranges, categorySelectionLimit } = this.props;
|
const { categoricalSelectionState } = this.props;
|
||||||
if (!ranges) return null;
|
if (!categoricalSelectionState) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -47,27 +26,9 @@ class Categories extends React.Component {
|
|||||||
>
|
>
|
||||||
Categorical Metadata
|
Categorical Metadata
|
||||||
</p>
|
</p>
|
||||||
{_.map(ranges, (value, key) => {
|
{_.map(categoricalSelectionState, (catState, catName) => (
|
||||||
const isColorField = key.includes("color") || key.includes("Color");
|
<Category key={catName} metadataField={catName} />
|
||||||
const isSelectableCategory =
|
))}
|
||||||
value.options &&
|
|
||||||
!isColorField &&
|
|
||||||
key !== "name" &&
|
|
||||||
value.numOptions < categorySelectionLimit;
|
|
||||||
|
|
||||||
if (isSelectableCategory) {
|
|
||||||
const categoryOptions = truncateCategories(value.options);
|
|
||||||
return (
|
|
||||||
<Category
|
|
||||||
key={key}
|
|
||||||
metadataField={key}
|
|
||||||
values={categoryOptions}
|
|
||||||
isTruncated={categoryOptions !== value.options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,15 @@ import React from "react";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||||
import memoize from "memoize-one";
|
import { Button, Tooltip } from "@blueprintjs/core";
|
||||||
import { Button, Tooltip, Position } from "@blueprintjs/core";
|
|
||||||
|
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import Value from "./value";
|
import Value from "./value";
|
||||||
import alphabeticallySortedValues from "./util";
|
import alphabeticallySortedValues from "./util";
|
||||||
|
|
||||||
const countCategories = (values, optsAsBools) =>
|
|
||||||
_.reduce(
|
|
||||||
values,
|
|
||||||
(r, v, k) => {
|
|
||||||
r.total += 1;
|
|
||||||
if (optsAsBools[k]) {
|
|
||||||
r.on += 1;
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
{ total: 0, on: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
colorAccessor: state.controls.colorAccessor,
|
colorAccessor: state.controls.colorAccessor,
|
||||||
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap
|
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||||
}))
|
}))
|
||||||
class Category extends React.Component {
|
class Category extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -33,24 +19,30 @@ class Category extends React.Component {
|
|||||||
isChecked: true,
|
isChecked: true,
|
||||||
isExpanded: false
|
isExpanded: false
|
||||||
};
|
};
|
||||||
this.countCategories = memoize((values, optsAsBools) =>
|
|
||||||
countCategories(values, optsAsBools)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate() {
|
||||||
const { categoricalAsBooleansMap, metadataField, values } = this.props;
|
const { categoricalSelectionState, metadataField } = this.props;
|
||||||
const categoryCount = this.countCategories(
|
const cat = categoricalSelectionState[metadataField];
|
||||||
values,
|
const categoryCount = {
|
||||||
categoricalAsBooleansMap[metadataField]
|
// total number of options in this category
|
||||||
);
|
totalOptionCount: cat.numOptions,
|
||||||
if (categoryCount.on === categoryCount.total) {
|
// number of selected options in this category
|
||||||
|
selectedOptionCount: _.reduce(
|
||||||
|
cat.optionSelected,
|
||||||
|
(res, cond) => (cond ? res + 1 : res),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if (categoryCount.selectedOptionCount === categoryCount.totalOptionCount) {
|
||||||
/* everything is on, so not indeterminate */
|
/* everything is on, so not indeterminate */
|
||||||
this.checkbox.indeterminate = false;
|
this.checkbox.indeterminate = false;
|
||||||
} else if (categoryCount.on === 0) {
|
} else if (categoryCount.selectedOptionCount === 0) {
|
||||||
/* nothing is on, so no */
|
/* nothing is on, so no */
|
||||||
this.checkbox.indeterminate = false;
|
this.checkbox.indeterminate = false;
|
||||||
} else if (categoryCount.on < categoryCount.total) {
|
} else if (
|
||||||
|
categoryCount.selectedOptionCount < categoryCount.totalOptionCount
|
||||||
|
) {
|
||||||
/* to be explicit... */
|
/* to be explicit... */
|
||||||
this.checkbox.indeterminate = true;
|
this.checkbox.indeterminate = true;
|
||||||
}
|
}
|
||||||
@@ -74,11 +66,10 @@ class Category extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleNone() {
|
toggleNone() {
|
||||||
const { dispatch, metadataField, value } = this.props;
|
const { dispatch, metadataField } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "categorical metadata filter none of these",
|
type: "categorical metadata filter none of these",
|
||||||
metadataField,
|
metadataField
|
||||||
value
|
|
||||||
});
|
});
|
||||||
this.setState({ isChecked: false });
|
this.setState({ isChecked: false });
|
||||||
}
|
}
|
||||||
@@ -94,13 +85,15 @@ class Category extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderCategoryItems() {
|
renderCategoryItems() {
|
||||||
const { values, metadataField } = this.props;
|
const { categoricalSelectionState, metadataField } = this.props;
|
||||||
return _.map(alphabeticallySortedValues(values), (v, i) => (
|
|
||||||
|
const cat = categoricalSelectionState[metadataField];
|
||||||
|
const optTuples = alphabeticallySortedValues([...cat.optionIndex]);
|
||||||
|
return _.map(optTuples, (tuple, i) => (
|
||||||
<Value
|
<Value
|
||||||
key={v}
|
key={tuple[1]}
|
||||||
metadataField={metadataField}
|
metadataField={metadataField}
|
||||||
count={values[v]}
|
optionIndex={tuple[1]}
|
||||||
value={v}
|
|
||||||
i={i}
|
i={i}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -108,12 +101,15 @@ class Category extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isExpanded, isChecked } = this.state;
|
const { isExpanded, isChecked } = this.state;
|
||||||
const { metadataField, colorAccessor, isTruncated } = this.props;
|
const {
|
||||||
|
metadataField,
|
||||||
|
colorAccessor,
|
||||||
|
categoricalSelectionState
|
||||||
|
} = this.props;
|
||||||
|
const { isTruncated } = categoricalSelectionState[metadataField];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
// display: "flex",
|
|
||||||
// alignItems: "baseline",
|
|
||||||
maxWidth: globals.maxControlsWidth
|
maxWidth: globals.maxControlsWidth
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
|
|
||||||
|
// values is [ [optVal, optIdx], ...]
|
||||||
|
// index is range array
|
||||||
|
// return sorted index
|
||||||
export default values =>
|
export default values =>
|
||||||
Object.keys(values).sort((a, b) => {
|
values.sort((a, b) => {
|
||||||
const textA = a.toUpperCase();
|
const textA = String(a[0]).toUpperCase();
|
||||||
const textB = b.toUpperCase();
|
const textB = String(b[0]).toUpperCase();
|
||||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,45 +4,49 @@ import React from "react";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap,
|
categoricalSelectionState: state.controls.categoricalSelectionState,
|
||||||
colorScale: state.controls.colorScale,
|
colorScale: state.controls.colorScale,
|
||||||
colorAccessor: state.controls.colorAccessor,
|
colorAccessor: state.controls.colorAccessor,
|
||||||
schema: _.get(state.controls.world, "schema", null)
|
schema: _.get(state.controls.world, "schema", null)
|
||||||
}))
|
}))
|
||||||
class CategoryValue extends React.Component {
|
class CategoryValue extends React.Component {
|
||||||
toggleOff() {
|
toggleOff() {
|
||||||
const { dispatch, metadataField, value } = this.props;
|
const { dispatch, metadataField, optionIndex } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "categorical metadata filter deselect",
|
type: "categorical metadata filter deselect",
|
||||||
metadataField,
|
metadataField,
|
||||||
value
|
optionIndex
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOn() {
|
toggleOn() {
|
||||||
const { dispatch, metadataField, value } = this.props;
|
const { dispatch, metadataField, optionIndex } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "categorical metadata filter select",
|
type: "categorical metadata filter select",
|
||||||
metadataField,
|
metadataField,
|
||||||
value
|
optionIndex
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
categoricalAsBooleansMap,
|
categoricalSelectionState,
|
||||||
metadataField,
|
metadataField,
|
||||||
count,
|
optionIndex,
|
||||||
value,
|
|
||||||
colorAccessor,
|
colorAccessor,
|
||||||
colorScale,
|
colorScale,
|
||||||
i,
|
i,
|
||||||
schema
|
schema
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!categoricalAsBooleansMap) return null;
|
if (!categoricalSelectionState) return null;
|
||||||
|
|
||||||
|
const category = categoricalSelectionState[metadataField];
|
||||||
|
const selected = category.optionSelected[optionIndex];
|
||||||
|
const count = category.optionCount[optionIndex];
|
||||||
|
const value = category.optionValue[optionIndex];
|
||||||
|
const displayString = String(category.optionValue[optionIndex]).valueOf();
|
||||||
|
|
||||||
const selected = categoricalAsBooleansMap[metadataField][value];
|
|
||||||
/* this is the color scale, so add swatches below */
|
/* this is the color scale, so add swatches below */
|
||||||
const c = metadataField === colorAccessor;
|
const c = metadataField === colorAccessor;
|
||||||
let categories = null;
|
let categories = null;
|
||||||
@@ -78,7 +82,7 @@ class CategoryValue extends React.Component {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<span className="bp3-control-indicator" />
|
<span className="bp3-control-indicator" />
|
||||||
{value}
|
{displayString}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
Vendored
+151
-55
@@ -12,34 +12,109 @@ import {
|
|||||||
diffexpDimensionName,
|
diffexpDimensionName,
|
||||||
makeContinuousDimensionName
|
makeContinuousDimensionName
|
||||||
} from "../util/nameCreators";
|
} from "../util/nameCreators";
|
||||||
|
import { fillRange } from "../util/typedCrossfilter/util";
|
||||||
|
|
||||||
function createCategoricalAsBooleansMap(world) {
|
/*
|
||||||
|
Selection state for categoricals are tracked in an Object that
|
||||||
|
has two main components for each category:
|
||||||
|
1. mapping of option value to an index
|
||||||
|
2. array of bool selection state by index
|
||||||
|
Remember that option values can be ANY js type, except undefined/null.
|
||||||
|
|
||||||
|
{
|
||||||
|
_category_name_1: {
|
||||||
|
// map of option value to index
|
||||||
|
optionIndex: Map([
|
||||||
|
optval1: index,
|
||||||
|
...
|
||||||
|
])
|
||||||
|
|
||||||
|
// index->selection true/false state
|
||||||
|
optionSelected: [ true/false, true/false, ... ]
|
||||||
|
|
||||||
|
// number of options
|
||||||
|
numOptions: number,
|
||||||
|
|
||||||
|
// isTruncated - true if the options for selection has
|
||||||
|
// been truncated (ie, was too large to implement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
function topNoptions(summary) {
|
||||||
|
const counts = _.map(summary.categories, cat => summary.options[cat]);
|
||||||
|
const sortIndex = fillRange(new Array(summary.numOptions)).sort(
|
||||||
|
(a, b) => counts[b] - counts[a]
|
||||||
|
);
|
||||||
|
const sortedCategories = _.map(sortIndex, i => summary.categories[i]);
|
||||||
|
const sortedCounts = _.map(sortIndex, i => counts[i]);
|
||||||
|
const N = globals.maxCategoricalOptionsToDisplay;
|
||||||
|
|
||||||
|
if (sortedCategories.length < N) {
|
||||||
|
return [sortedCategories, sortedCounts];
|
||||||
|
}
|
||||||
|
return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCategoricalSelectionState(state, world) {
|
||||||
const res = {};
|
const res = {};
|
||||||
_.each(world.summary.obs, (value, key) => {
|
_.forEach(world.summary.obs, (value, key) => {
|
||||||
if (value.options && key !== "name") {
|
if (value.categories) {
|
||||||
const optionsAsBooleans = {};
|
const isColorField = key.includes("color") || key.includes("Color");
|
||||||
_.each(value.options, (_value, _key) => {
|
const isSelectableCategory =
|
||||||
optionsAsBooleans[_key] = true;
|
!isColorField &&
|
||||||
});
|
key !== "name" &&
|
||||||
res[key] = optionsAsBooleans;
|
value.categories.length < state.maxCategoryItems;
|
||||||
|
if (isSelectableCategory) {
|
||||||
|
const [optionValue, optionCount] = topNoptions(value);
|
||||||
|
// const optionCount = Object.values(value.options);
|
||||||
|
|
||||||
|
const optionIndex = new Map(optionValue.map((v, i) => [v, i]));
|
||||||
|
const numOptions = optionIndex.size;
|
||||||
|
const optionSelected = new Array(numOptions).fill(true);
|
||||||
|
const isTruncated = optionValue.length < value.numOptions;
|
||||||
|
res[key] = {
|
||||||
|
optionValue, // array: of natively typed option values
|
||||||
|
optionIndex, // map: option value (native type) -> option index
|
||||||
|
optionSelected, // array: t/f selection state
|
||||||
|
numOptions, // number: of options
|
||||||
|
isTruncated, // bool: true if list was truncated
|
||||||
|
optionCount // array: cardinality of each option
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
given a categoricalSelectionState, return the list of all option values
|
||||||
|
where selection state is true (ie, they are selected).
|
||||||
|
*/
|
||||||
|
function selectedValuesForCategory(categorySelectionState) {
|
||||||
|
const selectedValues = _([...categorySelectionState.optionIndex])
|
||||||
|
.filter(tuple => categorySelectionState.optionSelected[tuple[1]])
|
||||||
|
.map(tuple => tuple[0])
|
||||||
|
.value();
|
||||||
|
return selectedValues;
|
||||||
|
}
|
||||||
|
|
||||||
const Controls = (
|
const Controls = (
|
||||||
state = {
|
state = {
|
||||||
// data loading flag
|
// data loading flag
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
// configuration
|
||||||
|
maxCategoryItems: globals.configDefaults.parameters["max-category-items"],
|
||||||
|
|
||||||
|
// the whole big bang
|
||||||
universe: null,
|
universe: null,
|
||||||
|
|
||||||
// all of the data + selection state
|
// all of the data + selection state
|
||||||
world: null,
|
world: null,
|
||||||
colorName: null,
|
colorName: null,
|
||||||
colorRGB: null,
|
colorRGB: null,
|
||||||
categoricalAsBooleansMap: null,
|
categoricalSelectionState: null,
|
||||||
crossfilter: null,
|
crossfilter: null,
|
||||||
dimensionMap: null,
|
dimensionMap: null,
|
||||||
userDefinedGenes: [],
|
userDefinedGenes: [],
|
||||||
@@ -72,6 +147,17 @@ const Controls = (
|
|||||||
Initialization, World/Universe management
|
Initialization, World/Universe management
|
||||||
and data loading.
|
and data loading.
|
||||||
******************************************************/
|
******************************************************/
|
||||||
|
case "configuration load complete": {
|
||||||
|
// there are a couple of configuration items we need to retain
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
maxCategoryItems: _.get(
|
||||||
|
state.config,
|
||||||
|
"parameters.max-category-items",
|
||||||
|
globals.configDefaults.parameters["max-category-items"]
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
case "initial data load start": {
|
case "initial data load start": {
|
||||||
return { ...state, loading: true };
|
return { ...state, loading: true };
|
||||||
}
|
}
|
||||||
@@ -83,7 +169,10 @@ const Controls = (
|
|||||||
const world = World.createWorldFromEntireUniverse(universe);
|
const world = World.createWorldFromEntireUniverse(universe);
|
||||||
const colorName = new Array(universe.nObs).fill(globals.defaultCellColor);
|
const colorName = new Array(universe.nObs).fill(globals.defaultCellColor);
|
||||||
const colorRGB = _.map(colorName, c => parseRGB(c));
|
const colorRGB = _.map(colorName, c => parseRGB(c));
|
||||||
const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
|
const categoricalSelectionState = createCategoricalSelectionState(
|
||||||
|
state,
|
||||||
|
world
|
||||||
|
);
|
||||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||||
|
|
||||||
@@ -135,7 +224,7 @@ const Controls = (
|
|||||||
world,
|
world,
|
||||||
colorName,
|
colorName,
|
||||||
colorRGB,
|
colorRGB,
|
||||||
categoricalAsBooleansMap,
|
categoricalSelectionState,
|
||||||
crossfilter,
|
crossfilter,
|
||||||
dimensionMap,
|
dimensionMap,
|
||||||
colorAccessor: null
|
colorAccessor: null
|
||||||
@@ -152,7 +241,10 @@ const Controls = (
|
|||||||
);
|
);
|
||||||
const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
|
const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
|
||||||
const colorRGB = _.map(colorName, c => parseRGB(c));
|
const colorRGB = _.map(colorName, c => parseRGB(c));
|
||||||
const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
|
const categoricalSelectionState = createCategoricalSelectionState(
|
||||||
|
state,
|
||||||
|
world
|
||||||
|
);
|
||||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||||
|
|
||||||
@@ -197,7 +289,7 @@ const Controls = (
|
|||||||
world,
|
world,
|
||||||
colorName,
|
colorName,
|
||||||
colorRGB,
|
colorRGB,
|
||||||
categoricalAsBooleansMap,
|
categoricalSelectionState,
|
||||||
crossfilter,
|
crossfilter,
|
||||||
dimensionMap,
|
dimensionMap,
|
||||||
colorAccessor: null
|
colorAccessor: null
|
||||||
@@ -422,83 +514,87 @@ const Controls = (
|
|||||||
Categorical metadata
|
Categorical metadata
|
||||||
*******************************/
|
*******************************/
|
||||||
case "categorical metadata filter select": {
|
case "categorical metadata filter select": {
|
||||||
const newCategoricalAsBooleansMap = {
|
const newOptionSelected = Array.from(
|
||||||
...state.categoricalAsBooleansMap,
|
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||||
|
);
|
||||||
|
newOptionSelected[action.optionIndex] = true;
|
||||||
|
const newCategoricalSelectionState = {
|
||||||
|
...state.categoricalSelectionState,
|
||||||
[action.metadataField]: {
|
[action.metadataField]: {
|
||||||
...state.categoricalAsBooleansMap[action.metadataField],
|
...state.categoricalSelectionState[action.metadataField],
|
||||||
[action.value]: true
|
optionSelected: newOptionSelected
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// update the filter for the one category that changed state
|
|
||||||
|
// update the filter to match all selected options
|
||||||
|
const cat = newCategoricalSelectionState[action.metadataField];
|
||||||
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
|
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
|
||||||
_.filter(
|
selectedValuesForCategory(cat)
|
||||||
_.map(
|
|
||||||
newCategoricalAsBooleansMap[action.metadataField],
|
|
||||||
(val, key) => (val ? key : false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
categoricalSelectionState: newCategoricalSelectionState
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "categorical metadata filter deselect": {
|
case "categorical metadata filter deselect": {
|
||||||
const newCategoricalAsBooleansMap = {
|
const newOptionSelected = Array.from(
|
||||||
...state.categoricalAsBooleansMap,
|
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||||
|
);
|
||||||
|
newOptionSelected[action.optionIndex] = false;
|
||||||
|
const newCategoricalSelectionState = {
|
||||||
|
...state.categoricalSelectionState,
|
||||||
[action.metadataField]: {
|
[action.metadataField]: {
|
||||||
...state.categoricalAsBooleansMap[action.metadataField],
|
...state.categoricalSelectionState[action.metadataField],
|
||||||
[action.value]: false
|
optionSelected: newOptionSelected
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// update the filter for the one category that changed state
|
|
||||||
|
// update the filter to match all selected options
|
||||||
|
const cat = newCategoricalSelectionState[action.metadataField];
|
||||||
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
|
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
|
||||||
_.filter(
|
selectedValuesForCategory(cat)
|
||||||
_.map(
|
|
||||||
newCategoricalAsBooleansMap[action.metadataField],
|
|
||||||
(val, key) => (val ? key : false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
categoricalSelectionState: newCategoricalSelectionState
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "categorical metadata filter none of these": {
|
case "categorical metadata filter none of these": {
|
||||||
const newCategoricalAsBooleansMap = {
|
const newCategoricalSelectionState = {
|
||||||
...state.categoricalAsBooleansMap
|
...state.categoricalSelectionState,
|
||||||
};
|
[action.metadataField]: {
|
||||||
_.forEach(
|
...state.categoricalSelectionState[action.metadataField],
|
||||||
newCategoricalAsBooleansMap[action.metadataField],
|
optionSelected: Array.from(
|
||||||
(v, k, c) => {
|
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||||
c[k] = false;
|
).fill(false)
|
||||||
}
|
}
|
||||||
);
|
};
|
||||||
state.dimensionMap[
|
state.dimensionMap[
|
||||||
obsAnnoDimensionName(action.metadataField)
|
obsAnnoDimensionName(action.metadataField)
|
||||||
].filterNone();
|
].filterNone();
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
categoricalSelectionState: newCategoricalSelectionState
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "categorical metadata filter all of these": {
|
case "categorical metadata filter all of these": {
|
||||||
const newCategoricalAsBooleansMap = {
|
const newCategoricalSelectionState = {
|
||||||
...state.categoricalAsBooleansMap
|
...state.categoricalSelectionState,
|
||||||
};
|
[action.metadataField]: {
|
||||||
_.forEach(
|
...state.categoricalSelectionState[action.metadataField],
|
||||||
newCategoricalAsBooleansMap[action.metadataField],
|
optionSelected: Array.from(
|
||||||
(v, k, c) => {
|
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||||
c[k] = true;
|
).fill(true)
|
||||||
}
|
}
|
||||||
);
|
};
|
||||||
state.dimensionMap[
|
state.dimensionMap[
|
||||||
obsAnnoDimensionName(action.metadataField)
|
obsAnnoDimensionName(action.metadataField)
|
||||||
].filterAll();
|
].filterAll();
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
categoricalSelectionState: newCategoricalSelectionState
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ Example:
|
|||||||
|
|
||||||
NOTE: will not summarize the required 'name' annotation, as that is
|
NOTE: will not summarize the required 'name' annotation, as that is
|
||||||
specified as unique per element.
|
specified as unique per element.
|
||||||
|
|
||||||
|
TODO: XXX - this data structure coerces all metadata categories into a string
|
||||||
|
(ie, stores values as an Object property in the `options` field). This looses
|
||||||
|
information (eg, type) for category types which are not strings. Consider an
|
||||||
|
alterative data structure that does not use the object property for non-string
|
||||||
|
data types (and does not use _.countBy to summarize).
|
||||||
*/
|
*/
|
||||||
function summarizeDimension(schema, annotations) {
|
function summarizeDimension(schema, annotations) {
|
||||||
return _(schema)
|
return _(schema)
|
||||||
@@ -58,11 +64,13 @@ function summarizeDimension(schema, annotations) {
|
|||||||
const continuous = type === "int32" || type === "float32";
|
const continuous = type === "int32" || type === "float32";
|
||||||
|
|
||||||
if (!continuous) {
|
if (!continuous) {
|
||||||
|
const categories = _.uniq(_.flatMap(annotations, name));
|
||||||
const options = _.countBy(annotations, name);
|
const options = _.countBy(annotations, name);
|
||||||
const numOptions = _.size(options);
|
const numOptions = _.size(options);
|
||||||
return {
|
return {
|
||||||
numOptions,
|
numOptions,
|
||||||
options
|
options,
|
||||||
|
categories
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,32 @@ function RESTv02LayoutResponseToInternal(response) {
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reconcileSchemaCategoriesWithSummary(universe) {
|
||||||
|
/*
|
||||||
|
where we treat types as (essentially) categorical metadata, update
|
||||||
|
the schema with data-derived categories (in addition to those in
|
||||||
|
the server declared schema).
|
||||||
|
|
||||||
|
For example, boolean defined fields in the schema do not contain
|
||||||
|
explicit declaration of categories (nor do string fields). In these
|
||||||
|
cases, add a 'categories' field to the schema so it is accessible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
_.forEach(universe.schema.annotations.obs, s => {
|
||||||
|
if (
|
||||||
|
s.type === "string" ||
|
||||||
|
s.type === "boolean" ||
|
||||||
|
s.type === "categorical"
|
||||||
|
) {
|
||||||
|
const categories = _.union(
|
||||||
|
_.get(s, "categories", []),
|
||||||
|
_.get(universe.summary.obs[s.name], "categories", [])
|
||||||
|
);
|
||||||
|
s.categories = categories;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createUniverseFromRestV02Response(
|
export function createUniverseFromRestV02Response(
|
||||||
configResponse,
|
configResponse,
|
||||||
schemaResponse,
|
schemaResponse,
|
||||||
@@ -199,6 +225,7 @@ export function createUniverseFromRestV02Response(
|
|||||||
universe.varAnnotations
|
universe.varAnnotations
|
||||||
);
|
);
|
||||||
|
|
||||||
|
reconcileSchemaCategoriesWithSummary(universe);
|
||||||
return finalize(universe);
|
return finalize(universe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-15
@@ -20,35 +20,36 @@ Follow these steps to create a release.
|
|||||||
|
|
||||||
1. Preparation:
|
1. Preparation:
|
||||||
- Define the release version number, using [semantic versioning](https://semver.org/)
|
- Define the release version number, using [semantic versioning](https://semver.org/)
|
||||||
- Write the release title and release notes
|
- Write the release title and release notes and add to
|
||||||
|
[release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit)
|
||||||
2. Create a release branch, eg, `release-version`
|
2. Create a release branch, eg, `release-version`
|
||||||
3. In the release branch:
|
3. In the release branch:
|
||||||
- run `bumpversion --config-file .bumpversion.cfg [major | minor | patch]`
|
- Run `bumpversion --config-file .bumpversion.cfg [major | minor | patch]`
|
||||||
- clean up existing environment using `bin/clean`
|
- Clean up existing environment using `bin/clean`
|
||||||
- build the JS asserts using `bin/build-client`
|
- Build the JS asserts using `bin/build-client`
|
||||||
4. Commit and push the new branch
|
4. Commit and push the new branch
|
||||||
5. Create a PR for the release.
|
5. Create a PR for the release.
|
||||||
- [optional] As needed, conduct PR review.
|
- [optional] As needed, conduct PR review.
|
||||||
6. Merge to master
|
6. Merge to master
|
||||||
7. Create Github release using the version number and release notes ([instructions](https://help.github.com/articles/creating-releases/)).
|
7. Create Github release using the version number and release notes
|
||||||
|
([instructions](https://help.github.com/articles/creating-releases/)).
|
||||||
- Draft new release
|
- Draft new release
|
||||||
- Type version name matching release version number from (1)
|
- Type version name matching release version number from (1)
|
||||||
- Select `master` as release branch (ensure you merged the release PR)
|
- Select `master` as release branch (ensure you merged the release PR)
|
||||||
- Type title `Release {version num}`
|
- Type title `Release {version num}`
|
||||||
- [optional] check pre-release if this release is not ready for production
|
- [optional] Check pre-release if this release is not ready for production
|
||||||
- Publish Release
|
- Publish Release
|
||||||
8. Publish to pypi by performing the following steps
|
8. Publish to pypi by performing the following steps (assumes you have `setuptools` and `twine` installed and that you
|
||||||
(assumes you have `setuptools` and `twine` installed and that you have
|
have registered for pypi and have write access to the cellxgene pypi package)
|
||||||
registered for pypi and have write access to the cellxgene pypi package)
|
- Build the distribution by calling
|
||||||
- build the distribution by calling
|
`python setup.py sdist`
|
||||||
`python setup.py sdist`
|
|
||||||
inside the top-level directory
|
inside the top-level directory
|
||||||
- [optional] upload the package to test pypi
|
- [optional] Upload the package to test pypi
|
||||||
`twine upload --repository-url https://test.pypi.org/legacy/ dist/*`
|
`twine upload --repository-url https://test.pypi.org/legacy/ dist/*`
|
||||||
- [optional] test the test installation in a fresh virtual environment using
|
- [optional] Test the test installation in a fresh virtual environment using
|
||||||
`pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene`
|
`pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene`
|
||||||
- upload the package to real pypi using `twine upload dist/*`
|
- Upload the package to real pypi using `twine upload dist/*`
|
||||||
- [optional] test the installation in a fresh virtual environment using
|
- [optional] Test the installation in a fresh virtual environment using
|
||||||
`pip install cellxgene`
|
`pip install cellxgene`
|
||||||
|
|
||||||
The optional steps are for testing purposes, and are recommended
|
The optional steps are for testing purposes, and are recommended
|
||||||
|
|||||||
@@ -65,6 +65,24 @@ class ScanpyEngine(CXGDriver):
|
|||||||
else:
|
else:
|
||||||
raise KeyError(f"Annotation name {name}, specified in --{ax_name}_name does not exist.")
|
raise KeyError(f"Annotation name {name}, specified in --{ax_name}_name does not exist.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _can_cast_to_float32(ann):
|
||||||
|
if ann.dtype.kind == "f":
|
||||||
|
if not np.can_cast(ann.dtype, np.float32):
|
||||||
|
warnings.warn(f"Annotation {ann.name} will be converted to 32 bit float and may loose precision.")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _can_cast_to_int32(ann):
|
||||||
|
if ann.dtype.kind in ["i", "u"]:
|
||||||
|
if np.can_cast(ann.dtype, np.int32):
|
||||||
|
return True
|
||||||
|
ii32 = np.iinfo(np.int32)
|
||||||
|
if ann.min() >= ii32.min and ann.max() <= ii32.max:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _create_schema(self):
|
def _create_schema(self):
|
||||||
self.schema = {
|
self.schema = {
|
||||||
"dataframe": {
|
"dataframe": {
|
||||||
@@ -81,16 +99,18 @@ class ScanpyEngine(CXGDriver):
|
|||||||
curr_axis = getattr(self.data, str(ax))
|
curr_axis = getattr(self.data, str(ax))
|
||||||
for ann in curr_axis:
|
for ann in curr_axis:
|
||||||
ann_schema = {"name": ann}
|
ann_schema = {"name": ann}
|
||||||
data_kind = curr_axis[ann].dtype.kind
|
dtype = curr_axis[ann].dtype
|
||||||
if data_kind == "f":
|
data_kind = dtype.kind
|
||||||
|
|
||||||
|
if self._can_cast_to_float32(curr_axis[ann]):
|
||||||
ann_schema["type"] = "float32"
|
ann_schema["type"] = "float32"
|
||||||
elif data_kind in ["i", "u"]:
|
elif self._can_cast_to_int32(curr_axis[ann]):
|
||||||
ann_schema["type"] = "int32"
|
ann_schema["type"] = "int32"
|
||||||
elif data_kind == "?":
|
elif dtype == np.bool_:
|
||||||
ann_schema["type"] = "boolean"
|
ann_schema["type"] = "boolean"
|
||||||
elif data_kind == "O" and curr_axis[ann].dtype == "object":
|
elif data_kind == "O" and dtype == "object":
|
||||||
ann_schema["type"] = "string"
|
ann_schema["type"] = "string"
|
||||||
elif data_kind == "O" and curr_axis[ann].dtype == "category":
|
elif data_kind == "O" and dtype == "category":
|
||||||
ann_schema["type"] = "categorical"
|
ann_schema["type"] = "categorical"
|
||||||
ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist()
|
ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist()
|
||||||
else:
|
else:
|
||||||
|
|||||||
+1
-1
@@ -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.2.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
|
@click.version_option(version="0.2.3", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
|
||||||
def cli():
|
def cli():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ with open("server/requirements.txt") as fh:
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="cellxgene",
|
name="cellxgene",
|
||||||
version="0.2.0",
|
version="0.2.3",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
url="https://github.com/chanzuckerberg/cellxgene",
|
url="https://github.com/chanzuckerberg/cellxgene",
|
||||||
license="MIT",
|
license="MIT",
|
||||||
|
|||||||
Reference in New Issue
Block a user