Compare commits

..
7 Commits
Author SHA1 Message Date
Charlotte Weaver 28f5263c88 bump version (#475) 2018-11-27 15:43:19 -08:00
Bruce Martin 933fac5fff Allow float64 to down-cast to float32 (#472)
* warn if annotations will down-cast to float32

* correctly handle falsey data values
2018-11-27 11:17:30 -08:00
Charlotte Weaver 0c26f227fe bump version (#470) 2018-11-26 15:38:47 -08:00
Marcus Kinsella 2b90c747f5 Fix readme images on PyPi (#467)
But this time actually do that
2018-11-26 15:27:05 -08:00
Charlotte Weaver 43a4e087ef Fix formatting issues (#468)
- Fix step 8 bullets
- Appropriate capitalization
- Add link to release notes doc
2018-11-26 13:52:00 -08:00
Charlotte Weaver f2612707bb bumped version (#466) 2018-11-26 11:42:56 -08:00
Bruce Martin 138d30909a improve type handling for non-string annotation data (#465)
* improve type handling for non-string annotation data

* improve clarity of code
2018-11-26 10:32:29 -08:00
16 changed files with 299 additions and 182 deletions
+1 -1
View File
@@ -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}"
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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>
); );
} }
+34 -38
View File
@@ -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
}} }}
> >
+7 -3
View File
@@ -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;
}); });
+15 -11
View File
@@ -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>
+151 -55
View File
@@ -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
}; };
} }
+27
View File
@@ -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
View File
@@ -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
+26 -6
View File
@@ -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
View File
@@ -5,7 +5,7 @@ from .prepare import prepare
@click.group(name="cellxgene", context_settings=dict(max_content_width=85)) @click.group(name="cellxgene", context_settings=dict(max_content_width=85))
@click.version_option(version="0.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
+1 -1
View File
@@ -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",