mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-22 19:08:13 +08:00
368 lines
12 KiB
JavaScript
368 lines
12 KiB
JavaScript
// jshint esversion: 6
|
|
import _ from "lodash";
|
|
import { parseRGB } from "../util/parseRGB";
|
|
import { createSchemaByDataSniffing } from "../util/schema";
|
|
var crossfilter = require("../util/typedCrossfilter");
|
|
|
|
// Deduce the correct crossfilter dimension type from a metadata
|
|
// schema description.
|
|
//
|
|
function deduceDimensionType(attributes, fieldName) {
|
|
let dimensionType;
|
|
if (attributes.type === "string") {
|
|
dimensionType = "enum";
|
|
} else if (attributes.type === "int") {
|
|
dimensionType = Int32Array;
|
|
} else if (attributes.type === "float") {
|
|
dimensionType = Float32Array;
|
|
} else {
|
|
console.error(
|
|
`Warning - REST API returned unknown metadata schema (${
|
|
attributes.type
|
|
}) for field ${fieldName}.`
|
|
);
|
|
// skip it - we don't know what to do with this type
|
|
}
|
|
return dimensionType;
|
|
}
|
|
|
|
// Create view state from /cells data response. Used both during a data
|
|
// load and during a graph reset.
|
|
//
|
|
function createViewState(schema, data) {
|
|
const cellsMetadata = data.metadata.slice(0);
|
|
|
|
/*
|
|
construct a copy of the ranges object that only has categorical
|
|
replace all counts with bool flags
|
|
ie., everything starts out checked
|
|
we mutate this map in the actions below
|
|
*/
|
|
const categoricalAsBooleansMap = {};
|
|
_.each(data.ranges, (value, key) => {
|
|
if (
|
|
key !== "CellName" &&
|
|
value.options /* it's categorical, it has options instead of ranges */
|
|
) {
|
|
const optionsAsBooleans = {};
|
|
_.each(value.options, (_value, _key) => {
|
|
optionsAsBooleans[_key] = true;
|
|
});
|
|
categoricalAsBooleansMap[key] = optionsAsBooleans;
|
|
}
|
|
});
|
|
|
|
const graph = data.graph;
|
|
_.each(cellsMetadata, (cell, idx) => {
|
|
cell.__cellIndex__ = idx;
|
|
cell.__color__ =
|
|
"rgba(0,0,0,1)"; /* initial color for all cells in all charts */
|
|
cell.__colorRGB__ = parseRGB(cell.__color__);
|
|
cell.__x__ = graph[idx][1];
|
|
cell.__y__ = graph[idx][2];
|
|
});
|
|
|
|
// Build the selection crossfilter.
|
|
//
|
|
let cellsCrossfilter = crossfilter(cellsMetadata);
|
|
let cellsDimensionsMap = {};
|
|
cellsDimensionsMap.x = cellsCrossfilter.dimension(r => r.__x__, Float32Array);
|
|
cellsDimensionsMap.y = cellsCrossfilter.dimension(r => r.__y__, Float32Array);
|
|
|
|
// Now walk the schema and make an appropriate dimension for each
|
|
// metadata field. This is a simplistic mapping, and could be
|
|
// optmized to use smaller scalars (to save memory) or larger
|
|
// floating point where precision is needed.
|
|
//
|
|
_.forEach(schema, (attributes, key) => {
|
|
if (key !== "CellName") {
|
|
const dimensionType = deduceDimensionType(attributes, key);
|
|
if (dimensionType) {
|
|
cellsDimensionsMap[key] = cellsCrossfilter.dimension(
|
|
r => r[key],
|
|
dimensionType
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
cellsMetadata,
|
|
crossfilter: {
|
|
cells: cellsCrossfilter,
|
|
dimensionMap: cellsDimensionsMap
|
|
},
|
|
categoricalAsBooleansMap
|
|
};
|
|
}
|
|
|
|
const Controls = (
|
|
state = {
|
|
/* Universe - all cells known to us. Set once, during initial load */
|
|
_ranges: null /* this comes from initialize, this is universe */,
|
|
allGeneNames: null,
|
|
allCells: null /* this comes from cells endpoint, this is universe */,
|
|
allCellsMetadata: null /* this comes from cells endpoint, and is just the metadata for universe */,
|
|
allCellsMetadataMap: null,
|
|
|
|
/* View / World - all cells currently being displayed. May be a subset of Universe. */
|
|
cellsMetadata: null,
|
|
crossfilter: null /* the current user selection state */,
|
|
categoricalAsBooleansMap: null,
|
|
|
|
colorAccessor: null,
|
|
colorScale: null,
|
|
opacityForDeselectedCells: 0.2,
|
|
graphBrushSelection: null,
|
|
continuousSelection: null,
|
|
scatterplotXXaccessor: null, // just easier to read
|
|
scatterplotYYaccessor: null,
|
|
axesHaveBeenDrawn: false,
|
|
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
|
|
__storedStateForCelllist2__: null
|
|
},
|
|
action
|
|
) => {
|
|
switch (action.type) {
|
|
/**********************************
|
|
Keep a copy of 'universe'
|
|
***********************************/
|
|
case "initialize success": {
|
|
if (!action.data.data.schema) {
|
|
console.error("Warning - REST API omitted schema description.");
|
|
}
|
|
return Object.assign({}, state, {
|
|
_ranges: action.data.data.ranges,
|
|
allGeneNames: action.data.data.genes,
|
|
schema: action.data.data.schema
|
|
});
|
|
}
|
|
case "request cells success": {
|
|
// If we don't have a schema (bad server!), fake it by inferring
|
|
// important fields from the ranges element.
|
|
//
|
|
if (!state.schema) {
|
|
state.schema = createSchemaByDataSniffing(action.data.data.ranges);
|
|
}
|
|
|
|
/* Set viewable world to the provided cell data */
|
|
const viewState = createViewState(state.schema, action.data.data);
|
|
return Object.assign({}, state, {
|
|
/* Universe - initialize once */
|
|
allCells: state.allCells ? state.allCells : action.data,
|
|
allCellsMetadata: state.allCellsMetadata
|
|
? state.allCellsMetadata
|
|
: viewState.cellsMetadata,
|
|
allCellsMetadataMap: state.allCellsMetadataMap
|
|
? state.allCellsMetadataMap
|
|
: _.keyBy(viewState.cellsMetadata, "CellName"),
|
|
|
|
/* World */
|
|
...viewState,
|
|
|
|
graphBrushSelection: null /* if we are getting new cells from the server, the layout (probably? definitely?) just changed, so this is now irrelevant, and we WILL need to call a function to reset state of this kind when cells success happens */
|
|
});
|
|
}
|
|
/* * * * * * * * * * * * * * * * * *
|
|
User events
|
|
* * * * * * * * * * * * * * * * * */
|
|
case "reset graph": {
|
|
/* Reset viewable world to the entire Universe */
|
|
const viewState = createViewState(state.schema, state.allCells.data);
|
|
return Object.assign({}, state, {
|
|
...viewState
|
|
});
|
|
}
|
|
case "parallel coordinates axes have been drawn": {
|
|
return Object.assign({}, state, {
|
|
axesHaveBeenDrawn: true
|
|
});
|
|
}
|
|
case "continuous selection using parallel coords brushing": {
|
|
return Object.assign({}, state, {
|
|
continuousSelection: action.data,
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
case "graph brush selection change": {
|
|
state.crossfilter.dimensionMap.x.filterRange([
|
|
action.brushCoords.northwest[0],
|
|
action.brushCoords.southeast[0]
|
|
]);
|
|
state.crossfilter.dimensionMap.y.filterRange([
|
|
action.brushCoords.southeast[1],
|
|
action.brushCoords.northwest[1]
|
|
]);
|
|
return Object.assign({}, state, {
|
|
graphBrushSelection: action.brushCoords,
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
case "graph brush deselect": {
|
|
state.crossfilter.dimensionMap.x.filterAll();
|
|
state.crossfilter.dimensionMap.y.filterAll();
|
|
return Object.assign({}, state, {
|
|
graphBrushSelection: null,
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
case "continuous metadata histogram brush": {
|
|
// action.selection: metadata name being selected
|
|
// action.range: filter range, or null if deselected
|
|
if (!action.range) {
|
|
state.crossfilter.dimensionMap[action.selection].filterAll();
|
|
} else {
|
|
state.crossfilter.dimensionMap[action.selection].filterRange(
|
|
action.range
|
|
);
|
|
}
|
|
return Object.assign({}, state, {
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
case "change opacity deselected cells in 2d graph background":
|
|
return Object.assign({}, state, {
|
|
opacityForDeselectedCells: action.data
|
|
});
|
|
/*******************************
|
|
Categorical metadata
|
|
*******************************/
|
|
case "categorical metadata filter select": {
|
|
const newCategoricalAsBooleansMap = {
|
|
...state.categoricalAsBooleansMap,
|
|
[action.metadataField]: {
|
|
...state.categoricalAsBooleansMap[action.metadataField],
|
|
[action.value]: true
|
|
}
|
|
};
|
|
// update the filter for the one category that changed state
|
|
state.crossfilter.dimensionMap[action.metadataField].filterEnum(
|
|
_.filter(
|
|
_.map(
|
|
newCategoricalAsBooleansMap[action.metadataField],
|
|
(val, key) => (val ? key : false)
|
|
)
|
|
)
|
|
);
|
|
return Object.assign({}, state, {
|
|
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
case "categorical metadata filter deselect": {
|
|
const newCategoricalAsBooleansMap = {
|
|
...state.categoricalAsBooleansMap,
|
|
[action.metadataField]: {
|
|
...state.categoricalAsBooleansMap[action.metadataField],
|
|
[action.value]: false
|
|
}
|
|
};
|
|
// update the filter for the one category that changed state
|
|
state.crossfilter.dimensionMap[action.metadataField].filterEnum(
|
|
_.filter(
|
|
_.map(
|
|
newCategoricalAsBooleansMap[action.metadataField],
|
|
(val, key) => (val ? key : false)
|
|
)
|
|
)
|
|
);
|
|
return Object.assign({}, state, {
|
|
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
case "categorical metadata filter none of these": {
|
|
const newCategoricalAsBooleansMap = {
|
|
...state.categoricalAsBooleansMap
|
|
};
|
|
_.forEach(
|
|
newCategoricalAsBooleansMap[action.metadataField],
|
|
(v, k, c) => {
|
|
c[k] = false;
|
|
}
|
|
);
|
|
state.crossfilter.dimensionMap[action.metadataField].filterNone();
|
|
return Object.assign({}, state, {
|
|
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
case "categorical metadata filter all of these": {
|
|
const newCategoricalAsBooleansMap = {
|
|
...state.categoricalAsBooleansMap
|
|
};
|
|
_.forEach(
|
|
newCategoricalAsBooleansMap[action.metadataField],
|
|
(v, k, c) => {
|
|
c[k] = true;
|
|
}
|
|
);
|
|
state.crossfilter.dimensionMap[action.metadataField].filterAll();
|
|
return Object.assign({}, state, {
|
|
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
|
crossfilter: {
|
|
...state.crossfilter
|
|
}
|
|
});
|
|
}
|
|
/*******************************
|
|
Color Scale
|
|
*******************************/
|
|
case "color by continuous metadata":
|
|
return Object.assign({}, state, {
|
|
colorAccessor: action.colorAccessor,
|
|
cellsMetadata:
|
|
action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
|
|
colorScale: action.colorScale
|
|
});
|
|
case "color by expression":
|
|
return Object.assign({}, state, {
|
|
colorAccessor: action.gene,
|
|
cellsMetadata:
|
|
action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
|
|
colorScale: action.colorScale
|
|
});
|
|
case "color by categorical metadata":
|
|
return Object.assign({}, state, {
|
|
colorAccessor:
|
|
action.colorAccessor /* pass the scale through additionally, and it's a legend! */,
|
|
cellsMetadata:
|
|
action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
|
|
colorScale: action.colorScale
|
|
});
|
|
case "store current cell selection as differential set 1":
|
|
return Object.assign({}, state, {
|
|
__storedStateForCelllist1__: action.data
|
|
});
|
|
/*******************************
|
|
Scatterplot
|
|
*******************************/
|
|
case "set scatterplot x":
|
|
return Object.assign({}, state, {
|
|
scatterplotXXaccessor: action.data
|
|
});
|
|
case "set scatterplot y":
|
|
return Object.assign({}, state, {
|
|
scatterplotYYaccessor: action.data
|
|
});
|
|
default:
|
|
return state;
|
|
}
|
|
};
|
|
|
|
export default Controls;
|