diff --git a/client/src/components/categorical/categorical.js b/client/src/components/categorical/categorical.js
index c8f4ae4e1..25d4646cb 100644
--- a/client/src/components/categorical/categorical.js
+++ b/client/src/components/categorical/categorical.js
@@ -5,34 +5,13 @@ import { connect } from "react-redux";
import * as globals from "../../globals";
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 => ({
- ranges: _.get(state.controls.world, "summary.obs", null),
- categorySelectionLimit: _.get(
- state.config,
- "parameters.max-category-items",
- globals.configDefaults.parameters["max-category-items"]
- )
+ categoricalSelectionState: state.controls.categoricalSelectionState
}))
class Categories extends React.Component {
render() {
- const { ranges, categorySelectionLimit } = this.props;
- if (!ranges) return null;
+ const { categoricalSelectionState } = this.props;
+ if (!categoricalSelectionState) return null;
return (
Categorical Metadata
- {_.map(ranges, (value, key) => {
- const isColorField = key.includes("color") || key.includes("Color");
- const isSelectableCategory =
- value.options &&
- !isColorField &&
- key !== "name" &&
- value.numOptions < categorySelectionLimit;
-
- if (isSelectableCategory) {
- const categoryOptions = truncateCategories(value.options);
- return (
-
- );
- }
- return undefined;
- })}
+ {_.map(categoricalSelectionState, (catState, catName) => (
+
+ ))}
);
}
diff --git a/client/src/components/categorical/category.js b/client/src/components/categorical/category.js
index 41b05a361..3c1cebb41 100644
--- a/client/src/components/categorical/category.js
+++ b/client/src/components/categorical/category.js
@@ -2,29 +2,15 @@ import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
-import memoize from "memoize-one";
-import { Button, Tooltip, Position } from "@blueprintjs/core";
+import { Button, Tooltip } from "@blueprintjs/core";
import * as globals from "../../globals";
import Value from "./value";
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 => ({
colorAccessor: state.controls.colorAccessor,
- categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap
+ categoricalSelectionState: state.controls.categoricalSelectionState
}))
class Category extends React.Component {
constructor(props) {
@@ -33,24 +19,30 @@ class Category extends React.Component {
isChecked: true,
isExpanded: false
};
- this.countCategories = memoize((values, optsAsBools) =>
- countCategories(values, optsAsBools)
- );
}
componentDidUpdate() {
- const { categoricalAsBooleansMap, metadataField, values } = this.props;
- const categoryCount = this.countCategories(
- values,
- categoricalAsBooleansMap[metadataField]
- );
- if (categoryCount.on === categoryCount.total) {
+ const { categoricalSelectionState, metadataField } = this.props;
+ const cat = categoricalSelectionState[metadataField];
+ const categoryCount = {
+ // total number of options in this category
+ totalOptionCount: cat.numOptions,
+ // 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 */
this.checkbox.indeterminate = false;
- } else if (categoryCount.on === 0) {
+ } else if (categoryCount.selectedOptionCount === 0) {
/* nothing is on, so no */
this.checkbox.indeterminate = false;
- } else if (categoryCount.on < categoryCount.total) {
+ } else if (
+ categoryCount.selectedOptionCount < categoryCount.totalOptionCount
+ ) {
/* to be explicit... */
this.checkbox.indeterminate = true;
}
@@ -74,11 +66,10 @@ class Category extends React.Component {
}
toggleNone() {
- const { dispatch, metadataField, value } = this.props;
+ const { dispatch, metadataField } = this.props;
dispatch({
type: "categorical metadata filter none of these",
- metadataField,
- value
+ metadataField
});
this.setState({ isChecked: false });
}
@@ -94,13 +85,15 @@ class Category extends React.Component {
}
renderCategoryItems() {
- const { values, metadataField } = this.props;
- return _.map(alphabeticallySortedValues(values), (v, i) => (
+ const { categoricalSelectionState, metadataField } = this.props;
+
+ const cat = categoricalSelectionState[metadataField];
+ const optTuples = alphabeticallySortedValues([...cat.optionIndex]);
+ return _.map(optTuples, (tuple, i) => (
));
@@ -108,12 +101,15 @@ class Category extends React.Component {
render() {
const { isExpanded, isChecked } = this.state;
- const { metadataField, colorAccessor, isTruncated } = this.props;
+ const {
+ metadataField,
+ colorAccessor,
+ categoricalSelectionState
+ } = this.props;
+ const { isTruncated } = categoricalSelectionState[metadataField];
return (
diff --git a/client/src/components/categorical/util.js b/client/src/components/categorical/util.js
index 1f1dfd4a3..58707776c 100644
--- a/client/src/components/categorical/util.js
+++ b/client/src/components/categorical/util.js
@@ -1,7 +1,11 @@
// jshint esversion: 6
+
+// values is [ [optVal, optIdx], ...]
+// index is range array
+// return sorted index
export default values =>
- Object.keys(values).sort((a, b) => {
- const textA = a.toUpperCase();
- const textB = b.toUpperCase();
+ values.sort((a, b) => {
+ const textA = String(a[0]).toUpperCase();
+ const textB = String(b[0]).toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
diff --git a/client/src/components/categorical/value.js b/client/src/components/categorical/value.js
index 09e47fc11..f2a1afb82 100644
--- a/client/src/components/categorical/value.js
+++ b/client/src/components/categorical/value.js
@@ -4,45 +4,49 @@ import React from "react";
import _ from "lodash";
@connect(state => ({
- categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap,
+ categoricalSelectionState: state.controls.categoricalSelectionState,
colorScale: state.controls.colorScale,
colorAccessor: state.controls.colorAccessor,
schema: _.get(state.controls.world, "schema", null)
}))
class CategoryValue extends React.Component {
toggleOff() {
- const { dispatch, metadataField, value } = this.props;
+ const { dispatch, metadataField, optionIndex } = this.props;
dispatch({
type: "categorical metadata filter deselect",
metadataField,
- value
+ optionIndex
});
}
toggleOn() {
- const { dispatch, metadataField, value } = this.props;
+ const { dispatch, metadataField, optionIndex } = this.props;
dispatch({
type: "categorical metadata filter select",
metadataField,
- value
+ optionIndex
});
}
render() {
const {
- categoricalAsBooleansMap,
+ categoricalSelectionState,
metadataField,
- count,
- value,
+ optionIndex,
colorAccessor,
colorScale,
i,
schema
} = 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 */
const c = metadataField === colorAccessor;
let categories = null;
@@ -78,7 +82,7 @@ class CategoryValue extends React.Component {
type="checkbox"
/>
- {value}
+ {displayString}
diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js
index 09d9b79c4..81eac6712 100644
--- a/client/src/reducers/controls.js
+++ b/client/src/reducers/controls.js
@@ -12,34 +12,109 @@ import {
diffexpDimensionName,
makeContinuousDimensionName
} from "../util/nameCreators";
+import { fillRange } from "../util/typedCrossfilter/util";
+
+/*
+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 createCategoricalAsBooleansMap(world) {
+function createCategoricalSelectionState(state, world) {
const res = {};
- _.each(world.summary.obs, (value, key) => {
- if (value.options && key !== "name") {
- const optionsAsBooleans = {};
- _.each(value.options, (_value, _key) => {
- optionsAsBooleans[_key] = true;
- });
- res[key] = optionsAsBooleans;
+ _.forEach(world.summary.obs, (value, key) => {
+ if (value.categories) {
+ const isColorField = key.includes("color") || key.includes("Color");
+ const isSelectableCategory =
+ !isColorField &&
+ key !== "name" &&
+ 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;
}
+/*
+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 = (
state = {
// data loading flag
loading: false,
error: null,
+ // configuration
+ maxCategoryItems: globals.configDefaults.parameters["max-category-items"],
+
+ // the whole big bang
universe: null,
// all of the data + selection state
world: null,
colorName: null,
colorRGB: null,
- categoricalAsBooleansMap: null,
+ categoricalSelectionState: null,
crossfilter: null,
dimensionMap: null,
userDefinedGenes: [],
@@ -72,6 +147,17 @@ const Controls = (
Initialization, World/Universe management
and data loading.
******************************************************/
+ case "configuration load complete": {
+ // there are a couple of configuration items we need to retain
+ return {
+ ...state,
+ maxCategoryItems: _.get(
+ state.config,
+ "parameters.max-category-items",
+ globals.configDefaults.parameters["max-category-items"]
+ )
+ };
+ }
case "initial data load start": {
return { ...state, loading: true };
}
@@ -83,7 +169,10 @@ const Controls = (
const world = World.createWorldFromEntireUniverse(universe);
const colorName = new Array(universe.nObs).fill(globals.defaultCellColor);
const colorRGB = _.map(colorName, c => parseRGB(c));
- const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
+ const categoricalSelectionState = createCategoricalSelectionState(
+ state,
+ world
+ );
const crossfilter = Crossfilter(world.obsAnnotations);
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
@@ -135,7 +224,7 @@ const Controls = (
world,
colorName,
colorRGB,
- categoricalAsBooleansMap,
+ categoricalSelectionState,
crossfilter,
dimensionMap,
colorAccessor: null
@@ -152,7 +241,10 @@ const Controls = (
);
const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
const colorRGB = _.map(colorName, c => parseRGB(c));
- const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
+ const categoricalSelectionState = createCategoricalSelectionState(
+ state,
+ world
+ );
const crossfilter = Crossfilter(world.obsAnnotations);
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
@@ -197,7 +289,7 @@ const Controls = (
world,
colorName,
colorRGB,
- categoricalAsBooleansMap,
+ categoricalSelectionState,
crossfilter,
dimensionMap,
colorAccessor: null
@@ -422,83 +514,87 @@ const Controls = (
Categorical metadata
*******************************/
case "categorical metadata filter select": {
- const newCategoricalAsBooleansMap = {
- ...state.categoricalAsBooleansMap,
+ const newOptionSelected = Array.from(
+ state.categoricalSelectionState[action.metadataField].optionSelected
+ );
+ newOptionSelected[action.optionIndex] = true;
+ const newCategoricalSelectionState = {
+ ...state.categoricalSelectionState,
[action.metadataField]: {
- ...state.categoricalAsBooleansMap[action.metadataField],
- [action.value]: true
+ ...state.categoricalSelectionState[action.metadataField],
+ 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(
- _.filter(
- _.map(
- newCategoricalAsBooleansMap[action.metadataField],
- (val, key) => (val ? key : false)
- )
- )
+ selectedValuesForCategory(cat)
);
+
return {
...state,
- categoricalAsBooleansMap: newCategoricalAsBooleansMap
+ categoricalSelectionState: newCategoricalSelectionState
};
}
case "categorical metadata filter deselect": {
- const newCategoricalAsBooleansMap = {
- ...state.categoricalAsBooleansMap,
+ const newOptionSelected = Array.from(
+ state.categoricalSelectionState[action.metadataField].optionSelected
+ );
+ newOptionSelected[action.optionIndex] = false;
+ const newCategoricalSelectionState = {
+ ...state.categoricalSelectionState,
[action.metadataField]: {
- ...state.categoricalAsBooleansMap[action.metadataField],
- [action.value]: false
+ ...state.categoricalSelectionState[action.metadataField],
+ 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(
- _.filter(
- _.map(
- newCategoricalAsBooleansMap[action.metadataField],
- (val, key) => (val ? key : false)
- )
- )
+ selectedValuesForCategory(cat)
);
+
return {
...state,
- categoricalAsBooleansMap: newCategoricalAsBooleansMap
+ categoricalSelectionState: newCategoricalSelectionState
};
}
case "categorical metadata filter none of these": {
- const newCategoricalAsBooleansMap = {
- ...state.categoricalAsBooleansMap
- };
- _.forEach(
- newCategoricalAsBooleansMap[action.metadataField],
- (v, k, c) => {
- c[k] = false;
+ const newCategoricalSelectionState = {
+ ...state.categoricalSelectionState,
+ [action.metadataField]: {
+ ...state.categoricalSelectionState[action.metadataField],
+ optionSelected: Array.from(
+ state.categoricalSelectionState[action.metadataField].optionSelected
+ ).fill(false)
}
- );
+ };
state.dimensionMap[
obsAnnoDimensionName(action.metadataField)
].filterNone();
return {
...state,
- categoricalAsBooleansMap: newCategoricalAsBooleansMap
+ categoricalSelectionState: newCategoricalSelectionState
};
}
case "categorical metadata filter all of these": {
- const newCategoricalAsBooleansMap = {
- ...state.categoricalAsBooleansMap
- };
- _.forEach(
- newCategoricalAsBooleansMap[action.metadataField],
- (v, k, c) => {
- c[k] = true;
+ const newCategoricalSelectionState = {
+ ...state.categoricalSelectionState,
+ [action.metadataField]: {
+ ...state.categoricalSelectionState[action.metadataField],
+ optionSelected: Array.from(
+ state.categoricalSelectionState[action.metadataField].optionSelected
+ ).fill(true)
}
- );
+ };
state.dimensionMap[
obsAnnoDimensionName(action.metadataField)
].filterAll();
return {
...state,
- categoricalAsBooleansMap: newCategoricalAsBooleansMap
+ categoricalSelectionState: newCategoricalSelectionState
};
}
diff --git a/client/src/util/stateManager/summarizeAnnotations.js b/client/src/util/stateManager/summarizeAnnotations.js
index a17d85b16..734b0fe33 100644
--- a/client/src/util/stateManager/summarizeAnnotations.js
+++ b/client/src/util/stateManager/summarizeAnnotations.js
@@ -48,6 +48,12 @@ Example:
NOTE: will not summarize the required 'name' annotation, as that is
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) {
return _(schema)
@@ -58,11 +64,13 @@ function summarizeDimension(schema, annotations) {
const continuous = type === "int32" || type === "float32";
if (!continuous) {
+ const categories = _.uniq(_.flatMap(annotations, name));
const options = _.countBy(annotations, name);
const numOptions = _.size(options);
return {
numOptions,
- options
+ options,
+ categories
};
}
diff --git a/client/src/util/stateManager/universe.js b/client/src/util/stateManager/universe.js
index a0999ef6a..69b1354b0 100644
--- a/client/src/util/stateManager/universe.js
+++ b/client/src/util/stateManager/universe.js
@@ -161,6 +161,32 @@ function RESTv02LayoutResponseToInternal(response) {
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(
configResponse,
schemaResponse,
@@ -199,6 +225,7 @@ export function createUniverseFromRestV02Response(
universe.varAnnotations
);
+ reconcileSchemaCategoriesWithSummary(universe);
return finalize(universe);
}
diff --git a/server/app/scanpy_engine/scanpy_engine.py b/server/app/scanpy_engine/scanpy_engine.py
index 8b1845a2f..b73341998 100644
--- a/server/app/scanpy_engine/scanpy_engine.py
+++ b/server/app/scanpy_engine/scanpy_engine.py
@@ -65,6 +65,22 @@ def _alias_annotation_names(self, axis, name):
else:
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" and np.can_cast(ann.dtype, np.float32):
+ 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):
self.schema = {
"dataframe": {
@@ -81,16 +97,18 @@ def _create_schema(self):
curr_axis = getattr(self.data, str(ax))
for ann in curr_axis:
ann_schema = {"name": ann}
- data_kind = curr_axis[ann].dtype.kind
- if data_kind == "f":
+ dtype = curr_axis[ann].dtype
+ data_kind = dtype.kind
+
+ if self._can_cast_to_float32(curr_axis[ann]):
ann_schema["type"] = "float32"
- elif data_kind in ["i", "u"]:
+ elif self._can_cast_to_int32(curr_axis[ann]):
ann_schema["type"] = "int32"
- elif data_kind == "?":
+ elif dtype == np.bool_:
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"
- elif data_kind == "O" and curr_axis[ann].dtype == "category":
+ elif data_kind == "O" and dtype == "category":
ann_schema["type"] = "categorical"
ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist()
else: