mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-24 10:18:13 +08:00
Undo/redo (#659)
* immutable crossfilter * first cut at reducer refactor with cascade model * add initial redo/undo implementation * small optimization * integrate expression with history * add tests for new reducers and fix a couple of small initialization bugs * treat tiny lasso selections as a clear * better function name for clarity * fix undo for differential expression * remove logging * fix regression due to bad merge * cleanup and comments for clarity * improve undoable configuration for flexibility * fix stale comments * remove debugging code from production build * rename categoricalSelectionState * rename file * improve comments
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import cascadeReducers from "../../src/reducers/cascade";
|
||||
|
||||
describe("create", () => {
|
||||
test("from Array", () => {
|
||||
expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("from Map", () => {
|
||||
expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf(
|
||||
Function
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade", () => {
|
||||
test("expected arguments provided & cascade ordering", () => {
|
||||
const topLevelState = {};
|
||||
const topLevelAction = { type: "test" };
|
||||
|
||||
const reducer = cascadeReducers([
|
||||
[
|
||||
"foo",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({});
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 0;
|
||||
}
|
||||
],
|
||||
[
|
||||
"bar",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({ foo: 0 });
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 99;
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
const nextState = reducer(topLevelState, topLevelAction);
|
||||
expect(nextState).toStrictEqual({ foo: 0, bar: 99 });
|
||||
expect(topLevelState).toStrictEqual({});
|
||||
expect(topLevelAction).toStrictEqual({ type: "test" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import undoable from "../../src/reducers/undoable";
|
||||
|
||||
describe("create", () => {
|
||||
test("no keys", () => {
|
||||
expect(() => undoable(() => {})).toThrow();
|
||||
expect(() => undoable(() => {}, null)).toThrow();
|
||||
expect(() => undoable(() => {}, [])).toThrow();
|
||||
expect(() => undoable(() => {}, [], {})).toThrow();
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function);
|
||||
expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("handles undefined initial state", () => {
|
||||
expect(
|
||||
undoable(() => {}, ["a"])(undefined, { type: "test" })
|
||||
).toMatchObject({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo", () => {
|
||||
test("expected state modifications", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = state => {
|
||||
return { a: state.a + 1, b: state.b + 1 };
|
||||
};
|
||||
const undoableReducer = undoable(reducer, ["a"]);
|
||||
|
||||
const s1 = undoableReducer(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// test that only specified keys are undone
|
||||
const s2 = undoableReducer(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
|
||||
// test backstop when no more history
|
||||
const s3 = undoableReducer(s2, { type: "@@undoable/undo" });
|
||||
expect(s3).toMatchObject({ a: 0, b: 1001 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("redo", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = state => {
|
||||
return { a: state.a + 1, b: state.b + 1 };
|
||||
};
|
||||
let UR;
|
||||
|
||||
beforeEach(() => {
|
||||
UR = undoable(reducer, ["a"]);
|
||||
});
|
||||
|
||||
test("expected state modifications", () => {
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify undo->redo reverts state.
|
||||
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
|
||||
type: "@@undoable/redo"
|
||||
});
|
||||
expect(s2).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify backstop when no redo future
|
||||
const s3 = UR(s2, { type: "@@undoable/redo" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1001 });
|
||||
});
|
||||
|
||||
test("history cleared", () => {
|
||||
// verify future cleared upon a normal state transition
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
const s2 = UR(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
const s3 = UR(s2, { type: "test" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1002 });
|
||||
const s4 = UR(s3, { type: "@@undoable/redo" });
|
||||
expect(s4).toMatchObject({ a: 1, b: 1002 });
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
TODO:
|
||||
- historyLimit is enforced
|
||||
- action filters
|
||||
*/
|
||||
@@ -65,7 +65,7 @@ Set the view (world) to current selection. Placeholder for an async action
|
||||
which also does re-layout.
|
||||
*/
|
||||
const regraph = () => (dispatch, getState) => {
|
||||
const { universe, world, crossfilter } = getState().controls;
|
||||
const { universe, world, crossfilter } = getState();
|
||||
dispatch({
|
||||
type: "set World to current selection",
|
||||
universe,
|
||||
@@ -124,7 +124,7 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||
};
|
||||
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
const { universe } = state;
|
||||
/* preload data already in cache */
|
||||
let expressionData = _.transform(
|
||||
genes,
|
||||
@@ -164,7 +164,7 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||
dispatch({ type: "get single gene expression for coloring started" });
|
||||
try {
|
||||
await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
const { world } = getState();
|
||||
dispatch({
|
||||
type: "color by expression",
|
||||
gene,
|
||||
@@ -185,7 +185,7 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => {
|
||||
dispatch({ type: "request user defined gene started" });
|
||||
try {
|
||||
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
const { world } = getState();
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
@@ -240,7 +240,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
2. get expression data for each
|
||||
*/
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
const { universe } = state;
|
||||
|
||||
// Legal values are null, Array or TypedArray. Null is initial state.
|
||||
if (!set1) set1 = [];
|
||||
@@ -300,7 +300,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
};
|
||||
|
||||
const resetInterface = () => (dispatch, getState) => {
|
||||
const { universe } = getState().controls;
|
||||
const { universe } = getState();
|
||||
|
||||
dispatch({
|
||||
type: "clear all user defined genes"
|
||||
|
||||
@@ -15,13 +15,13 @@ import actions from "../../actions";
|
||||
import finiteExtent from "../../util/finiteExtent";
|
||||
|
||||
@connect(state => ({
|
||||
world: state.controls.world,
|
||||
world: state.world,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
crossfilter: state.crossfilter,
|
||||
differential: state.differential,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null)
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
obsAnnotations: _.get(state.world, "obsAnnotations", null)
|
||||
}))
|
||||
class HistogramBrush extends React.Component {
|
||||
calcHistogramCache = memoize((obsAnnotations, field, rangeMin, rangeMax) => {
|
||||
@@ -94,13 +94,14 @@ class HistogramBrush extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
onBrush(selection, x) {
|
||||
onBrush(selection, x, eventType) {
|
||||
const type = `continuous metadata histogram ${eventType}`;
|
||||
return () => {
|
||||
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||
|
||||
if (d3.event.selection) {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -111,7 +112,7 @@ class HistogramBrush extends React.Component {
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -157,7 +158,7 @@ class HistogramBrush extends React.Component {
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
type: "continuous metadata histogram end",
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -168,7 +169,7 @@ class HistogramBrush extends React.Component {
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
type: "continuous metadata histogram end",
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -289,7 +290,12 @@ class HistogramBrush extends React.Component {
|
||||
.call(
|
||||
d3
|
||||
.brushX()
|
||||
.on("brush", this.onBrush(field, x.invert).bind(this))
|
||||
/*
|
||||
emit start so that the Undoable history can save an undo point
|
||||
upon drag start, and ignore the subsequent intermediate drag events.
|
||||
*/
|
||||
.on("start", this.onBrush(field, x.invert, "start").bind(this))
|
||||
.on("brush", this.onBrush(field, x.invert, "brush").bind(this))
|
||||
.on("end", this.onBrushEnd(field, x.invert).bind(this))
|
||||
);
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import * as globals from "../../globals";
|
||||
import Category from "./category";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||
categoricalSelection: state.categoricalSelection
|
||||
}))
|
||||
class Categories extends React.Component {
|
||||
render() {
|
||||
const { categoricalSelectionState } = this.props;
|
||||
if (!categoricalSelectionState) return null;
|
||||
const { categoricalSelection } = this.props;
|
||||
if (!categoricalSelection) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -26,7 +26,7 @@ class Categories extends React.Component {
|
||||
>
|
||||
Categorical Metadata
|
||||
</p>
|
||||
{_.map(categoricalSelectionState, (catState, catName) => (
|
||||
{_.map(categoricalSelection, (catState, catName) => (
|
||||
<Category key={catName} metadataField={catName} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,8 @@ import Value from "./value";
|
||||
import sortedCategoryValues from "./util";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection
|
||||
}))
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -22,9 +22,9 @@ class Category extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelectionState, metadataField } = this.props;
|
||||
if (categoricalSelectionState !== prevProps.categoricalSelectionState) {
|
||||
const cat = categoricalSelectionState[metadataField];
|
||||
const { categoricalSelection, metadataField } = this.props;
|
||||
if (categoricalSelection !== prevProps.categoricalSelection) {
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const categoryCount = {
|
||||
// total number of categories in this dimension
|
||||
totalCatCount: cat.numCategories,
|
||||
@@ -87,9 +87,9 @@ class Category extends React.Component {
|
||||
}
|
||||
|
||||
renderCategoryItems() {
|
||||
const { categoricalSelectionState, metadataField } = this.props;
|
||||
const { categoricalSelection, metadataField } = this.props;
|
||||
|
||||
const cat = categoricalSelectionState[metadataField];
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const optTuples = sortedCategoryValues([...cat.categoryIndices]);
|
||||
return _.map(optTuples, (tuple, i) => (
|
||||
<Value
|
||||
@@ -104,12 +104,8 @@ class Category extends React.Component {
|
||||
|
||||
render() {
|
||||
const { isExpanded, isChecked } = this.state;
|
||||
const {
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
categoricalSelectionState
|
||||
} = this.props;
|
||||
const { isTruncated } = categoricalSelectionState[metadataField];
|
||||
const { metadataField, colorAccessor, categoricalSelection } = this.props;
|
||||
const { isTruncated } = categoricalSelection[metadataField];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -10,7 +10,7 @@ class Occupancy extends React.Component {
|
||||
const {
|
||||
occupancy,
|
||||
colorScale,
|
||||
categoricalSelectionState,
|
||||
categoricalSelection,
|
||||
colorAccessor,
|
||||
schema
|
||||
} = this.props;
|
||||
@@ -29,23 +29,21 @@ class Occupancy extends React.Component {
|
||||
|
||||
let currentOffset = 0;
|
||||
|
||||
const stacks = categoricalSelectionState[colorAccessor].categoryValues.map(
|
||||
d => {
|
||||
const o = occupancy.get(d);
|
||||
const stacks = categoricalSelection[colorAccessor].categoryValues.map(d => {
|
||||
const o = occupancy.get(d);
|
||||
|
||||
const scaledValue = x(o);
|
||||
const scaledValue = x(o);
|
||||
|
||||
const stackItem = {
|
||||
key: d,
|
||||
value: o || 0,
|
||||
rectWidth: o ? scaledValue : 0,
|
||||
offset: currentOffset,
|
||||
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)"
|
||||
};
|
||||
currentOffset += o ? scaledValue : 0;
|
||||
return stackItem;
|
||||
}
|
||||
);
|
||||
const stackItem = {
|
||||
key: d,
|
||||
value: o || 0,
|
||||
rectWidth: o ? scaledValue : 0,
|
||||
offset: currentOffset,
|
||||
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)"
|
||||
};
|
||||
currentOffset += o ? scaledValue : 0;
|
||||
return stackItem;
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -7,11 +7,11 @@ import { countCategoryValues2D } from "../../util/stateManager/worldUtil";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState,
|
||||
colorScale: state.controls.colors.scale,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
schema: _.get(state.controls.world, "schema", null),
|
||||
world: state.controls.world
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
schema: _.get(state.world, "schema", null),
|
||||
world: state.world
|
||||
}))
|
||||
class CategoryValue extends React.Component {
|
||||
toggleOff() {
|
||||
@@ -34,7 +34,7 @@ class CategoryValue extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
categoricalSelectionState,
|
||||
categoricalSelection,
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
colorAccessor,
|
||||
@@ -44,9 +44,9 @@ class CategoryValue extends React.Component {
|
||||
world
|
||||
} = this.props;
|
||||
|
||||
if (!categoricalSelectionState) return null;
|
||||
if (!categoricalSelection) return null;
|
||||
|
||||
const category = categoricalSelectionState[metadataField];
|
||||
const category = categoricalSelection[metadataField];
|
||||
const selected = category.categorySelected[categoryIndex];
|
||||
const count = category.categoryCounts[categoryIndex];
|
||||
const value = category.categoryValues[categoryIndex];
|
||||
@@ -65,11 +65,7 @@ class CategoryValue extends React.Component {
|
||||
})[0].categories;
|
||||
}
|
||||
|
||||
if (
|
||||
colorAccessor &&
|
||||
!isColorBy &&
|
||||
categoricalSelectionState[colorAccessor]
|
||||
) {
|
||||
if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) {
|
||||
occupancy = countCategoryValues2D(
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
@@ -112,7 +108,7 @@ class CategoryValue extends React.Component {
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{colorAccessor &&
|
||||
!isColorBy &&
|
||||
categoricalSelectionState[colorAccessor] ? (
|
||||
categoricalSelection[colorAccessor] ? (
|
||||
<Occupancy
|
||||
occupancy={occupancy.get(
|
||||
category.categoryValues[categoryIndex]
|
||||
|
||||
@@ -9,10 +9,10 @@ import * as globals from "../../globals";
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
|
||||
@connect(state => ({
|
||||
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null),
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
schema: _.get(state.controls.world, "schema", null)
|
||||
obsAnnotations: _.get(state.world, "obsAnnotations", null),
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
schema: _.get(state.world, "schema", null)
|
||||
}))
|
||||
class Continuous extends React.Component {
|
||||
constructor(props) {
|
||||
|
||||
@@ -98,8 +98,8 @@ const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||
};
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
responsive: state.responsive
|
||||
}))
|
||||
class ContinuousLegend extends React.Component {
|
||||
|
||||
@@ -9,8 +9,8 @@ import CellSetButton from "./cellSetButtons";
|
||||
|
||||
@connect(state => ({
|
||||
differential: state.differential,
|
||||
world: state.controls.world,
|
||||
crossfilter: state.controls.crossfilter
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter
|
||||
}))
|
||||
class Expression extends React.Component {
|
||||
constructor(props) {
|
||||
|
||||
@@ -58,11 +58,11 @@ const filterGenes = (query, genes) =>
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null),
|
||||
obsAnnotations: _.get(state.world, "obsAnnotations", null),
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||
world: state.controls.world,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
world: state.world,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
differential: state.differential
|
||||
};
|
||||
})
|
||||
@@ -118,11 +118,9 @@ class GeneExpression extends React.Component {
|
||||
} else if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
|
||||
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||
} else {
|
||||
dispatch({ type: "single user defined gene start" });
|
||||
dispatch(actions.requestUserDefinedGene(gene));
|
||||
dispatch({
|
||||
type: "user defined gene",
|
||||
data: gene
|
||||
});
|
||||
dispatch({ type: "single user defined gene complete" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +135,7 @@ class GeneExpression extends React.Component {
|
||||
if (bulkAdd !== "") {
|
||||
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||
|
||||
dispatch({ type: "bulk user defined gene start" });
|
||||
genes.forEach(gene => {
|
||||
if (gene.length === 0) {
|
||||
keepAroundErrorToast("Must enter a gene name.");
|
||||
@@ -150,12 +149,9 @@ class GeneExpression extends React.Component {
|
||||
);
|
||||
} else {
|
||||
dispatch(actions.requestUserDefinedGene(gene));
|
||||
dispatch({
|
||||
type: "user defined gene",
|
||||
data: gene
|
||||
});
|
||||
}
|
||||
});
|
||||
dispatch({ type: "bulk user defined gene complete" });
|
||||
}
|
||||
|
||||
this.setState({ bulkAdd: "" });
|
||||
|
||||
@@ -26,21 +26,23 @@ import { World } from "../../util/stateManager";
|
||||
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
|
||||
|
||||
@connect(state => ({
|
||||
world: state.controls.world,
|
||||
universe: state.controls.universe,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
world: state.world,
|
||||
universe: state.universe,
|
||||
crossfilter: state.crossfilter,
|
||||
responsive: state.responsive,
|
||||
colorRGB: _.get(state.controls, "colors.rgb", null),
|
||||
colorRGB: state.colors.rgb,
|
||||
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||
resettingInterface: state.controls.resettingInterface,
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
diffexpGenes: state.controls.diffexpGenes,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
celllist1: state.differential.celllist1,
|
||||
celllist2: state.differential.celllist2,
|
||||
library_versions: _.get(state.config, "library_versions", null)
|
||||
library_versions: _.get(state.config, "library_versions", null),
|
||||
undoDisabled: state["@@undoable/past"].length === 0,
|
||||
redoDisabled: state["@@undoable/future"].length === 0
|
||||
}))
|
||||
class Graph extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -392,12 +394,18 @@ class Graph extends React.Component {
|
||||
|
||||
// when a lasso is completed, filter to the points within the lasso polygon
|
||||
handleLassoEnd(polygon) {
|
||||
const minimumPolygoneArea = 10;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "lasso selection",
|
||||
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
|
||||
});
|
||||
if (polygon.length < 3 || d3.polygonArea(polygon) < minimumPolygoneArea) {
|
||||
// if less than three points, or super small area, treat as a clear selection.
|
||||
dispatch({ type: "lasso deselect" });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "lasso selection",
|
||||
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOpacityRangeChange(e) {
|
||||
@@ -414,7 +422,9 @@ class Graph extends React.Component {
|
||||
responsive,
|
||||
crossfilter,
|
||||
resettingInterface,
|
||||
library_versions
|
||||
library_versions,
|
||||
undoDisabled,
|
||||
redoDisabled
|
||||
} = this.props;
|
||||
const { mode } = this.state;
|
||||
return (
|
||||
@@ -502,6 +512,32 @@ class Graph extends React.Component {
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Undo" position="left">
|
||||
<AnchorButton
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-undo"
|
||||
disabled={undoDisabled}
|
||||
onClick={() => {
|
||||
dispatch({ type: "@@undoable/undo" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Redo" position="left">
|
||||
<AnchorButton
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-redo"
|
||||
disabled={redoDisabled}
|
||||
onClick={() => {
|
||||
dispatch({ type: "@@undoable/redo" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 10 }}>
|
||||
|
||||
@@ -22,12 +22,8 @@ import { margin, width, height } from "./util";
|
||||
import finiteExtent from "../../util/finiteExtent";
|
||||
|
||||
@connect(state => {
|
||||
const {
|
||||
world,
|
||||
crossfilter,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor
|
||||
} = state.controls;
|
||||
const { world, crossfilter } = state;
|
||||
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
|
||||
const expressionX =
|
||||
world &&
|
||||
scatterplotXXaccessor &&
|
||||
@@ -44,9 +40,9 @@ import finiteExtent from "../../util/finiteExtent";
|
||||
return {
|
||||
world,
|
||||
|
||||
colorRGB: _.get(state.controls, "colors.rgb", null),
|
||||
colorScale: _.get(state.controls, "colors.scale", null),
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorRGB: state.colors.rgb,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
|
||||
// Accessors are var/gene names (strings)
|
||||
scatterplotXXaccessor,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export default function cascadeReducers(arg) {
|
||||
/*
|
||||
Combined a set of cascading reducers into a single reducer. Cascading
|
||||
reducers are reducers which may rely on state computed by another reducer.
|
||||
Therefore, they:
|
||||
- must be composed in a particular order (currently, this is a simple
|
||||
linear list of reducers, run in list order)
|
||||
- must have access to partially updated "next state" so they can further
|
||||
derive state.
|
||||
|
||||
Parameter is one of:
|
||||
- a Map object
|
||||
- an array of tuples, [ [key1, reducer1], [key2, reducer2], ... ]
|
||||
Ie, cascadeReducers([ ["a", reduceA], ["b", reduceB] ])
|
||||
|
||||
Each reducer will be called with the sigature:
|
||||
(prevState, action, sharedNextState, sharedPrevState) => newState
|
||||
|
||||
cascadeReducers will build a composite newState object, much
|
||||
like combinedReducers. Additional semantics:
|
||||
- reducers guaranteed to be called in order
|
||||
- each reducer will receive shared objects
|
||||
*/
|
||||
const reducers = arg instanceof Map ? arg : new Map(arg);
|
||||
const reducerKeys = [...reducers.keys()];
|
||||
return (prevState, action) => {
|
||||
const nextState = {};
|
||||
let stateChange = false;
|
||||
for (let i = 0, l = reducerKeys.length; i < l; i += 1) {
|
||||
const key = reducerKeys[i];
|
||||
const reducer = reducers.get(key);
|
||||
const prevStateForKey = prevState ? prevState[key] : undefined;
|
||||
const nextStateForKey = reducer(
|
||||
prevStateForKey,
|
||||
action,
|
||||
nextState,
|
||||
prevState
|
||||
);
|
||||
nextState[key] = nextStateForKey;
|
||||
stateChange = stateChange || nextStateForKey !== prevStateForKey;
|
||||
}
|
||||
return stateChange ? nextState : prevState;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import { ControlsHelpers } from "../util/stateManager";
|
||||
import * as globals from "../globals";
|
||||
|
||||
function maxCategoryItems(state) {
|
||||
return _.get(
|
||||
state.config,
|
||||
"parameters.max-category-items",
|
||||
globals.configDefaults.parameters["max-category-items"]
|
||||
);
|
||||
}
|
||||
|
||||
const CategoricalSelection = (
|
||||
state,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "set World to current selection":
|
||||
case "reset World to eq Universe": {
|
||||
const { world } = nextSharedState;
|
||||
return ControlsHelpers.createCategoricalSelection(
|
||||
maxCategoryItems(prevSharedState),
|
||||
world
|
||||
);
|
||||
}
|
||||
|
||||
case "categorical metadata filter select": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const newCategorySelected = Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = true;
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter deselect": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const newCategorySelected = Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = false;
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter none of these": {
|
||||
/*
|
||||
set all categories in this field to false.
|
||||
*/
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
).fill(false)
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter all of these": {
|
||||
/*
|
||||
set all categories in this field to true.
|
||||
*/
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state[action.metadataField].categorySelected
|
||||
).fill(true)
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default CategoricalSelection;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createColors } from "../util/stateManager";
|
||||
|
||||
const ColorsReducer = (
|
||||
state = {
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
rgb: null,
|
||||
scale: null
|
||||
},
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "reset World to eq Universe": {
|
||||
const { world } = nextSharedState;
|
||||
const colorMode = null;
|
||||
const colorAccessor = null;
|
||||
const { rgb, scale } = createColors(world, colorMode);
|
||||
return {
|
||||
...state,
|
||||
colorAccessor,
|
||||
colorMode,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "set World to current selection": {
|
||||
const { colorMode, colorAccessor } = state;
|
||||
const { world } = nextSharedState;
|
||||
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
|
||||
return {
|
||||
...state,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "reset colorscale": {
|
||||
const { world } = prevSharedState;
|
||||
const { rgb, scale } = createColors(world);
|
||||
return {
|
||||
...state,
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
const { world } = prevSharedState;
|
||||
const { rgb, scale } = createColors(
|
||||
world,
|
||||
action.type,
|
||||
action.colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
colorMode: action.type,
|
||||
colorAccessor: action.colorAccessor,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "color by expression": {
|
||||
const { world } = prevSharedState;
|
||||
const { rgb, scale } = createColors(world, action.type, action.gene);
|
||||
return {
|
||||
...state,
|
||||
colorMode: action.type,
|
||||
colorAccessor: action.gene,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ColorsReducer;
|
||||
Vendored
+10
-439
@@ -2,21 +2,7 @@
|
||||
|
||||
import _ from "lodash";
|
||||
|
||||
import {
|
||||
World,
|
||||
WorldUtil,
|
||||
ControlsHelpers,
|
||||
createColors
|
||||
} from "../util/stateManager";
|
||||
import Crossfilter from "../util/typedCrossfilter";
|
||||
import * as globals from "../globals";
|
||||
import {
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName,
|
||||
makeContinuousDimensionName
|
||||
} from "../util/nameCreators";
|
||||
import { WorldUtil } from "../util/stateManager";
|
||||
|
||||
const Controls = (
|
||||
state = {
|
||||
@@ -24,39 +10,23 @@ const Controls = (
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// configuration
|
||||
maxCategoryItems: globals.configDefaults.parameters["max-category-items"],
|
||||
|
||||
// the whole big bang
|
||||
universe: null,
|
||||
fullUniverseCache: null,
|
||||
|
||||
// all of the data + selection state
|
||||
world: null,
|
||||
categoricalSelectionState: null,
|
||||
crossfilter: null,
|
||||
userDefinedGenes: [],
|
||||
userDefinedGenesLoading: false,
|
||||
diffexpGenes: [],
|
||||
|
||||
// graph color-by
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
colors: {},
|
||||
|
||||
resettingInterface: false,
|
||||
|
||||
opacityForDeselectedCells: 0.2,
|
||||
graphBrushSelection: null,
|
||||
continuousSelection: null,
|
||||
scatterplotXXaccessor: null, // just easier to read
|
||||
scatterplotYYaccessor: null,
|
||||
axesHaveBeenDrawn: false,
|
||||
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
|
||||
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
|
||||
__storedStateForCelllist2__: null
|
||||
},
|
||||
action
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
/*
|
||||
For now, log anything looking like an error to the console.
|
||||
@@ -70,190 +40,32 @@ 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 };
|
||||
}
|
||||
case "initial data load complete (universe exists)": {
|
||||
/* first light - create world & other data-driven defaults */
|
||||
const { universe } = action;
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
const colorMode = null;
|
||||
const colors = createColors(world, colorMode);
|
||||
const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState(
|
||||
state,
|
||||
world
|
||||
);
|
||||
const crossfilter = World.createObsDimensions(
|
||||
new Crossfilter(world.obsAnnotations),
|
||||
world
|
||||
);
|
||||
WorldUtil.clearCaches();
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: null,
|
||||
universe,
|
||||
fullUniverseCache: { world, crossfilter },
|
||||
world,
|
||||
categoricalSelectionState,
|
||||
crossfilter,
|
||||
colorMode,
|
||||
colorAccessor: null,
|
||||
colors,
|
||||
resettingInterface: false
|
||||
};
|
||||
}
|
||||
case "reset World to eq Universe": {
|
||||
/*
|
||||
1. Reset world & crossfilter, using previously created objects which were
|
||||
stashed in `fullUniverseCache`
|
||||
2. Add crossfilter dimension for all userDefined and diffexp genes/varData,
|
||||
as they are not part of the cached crossfilter.
|
||||
3. Compute categorical selection summary
|
||||
4. Reset all WorldUtil caches
|
||||
5. Reset color-by
|
||||
*/
|
||||
const { userDefinedGenes, diffexpGenes, fullUniverseCache } = state;
|
||||
const { world } = fullUniverseCache;
|
||||
const crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
fullUniverseCache.crossfilter
|
||||
);
|
||||
const colorMode = null;
|
||||
const colors = createColors(world, colorMode);
|
||||
const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState(
|
||||
state,
|
||||
world
|
||||
);
|
||||
WorldUtil.clearCaches();
|
||||
return {
|
||||
...state,
|
||||
world,
|
||||
categoricalSelectionState,
|
||||
crossfilter,
|
||||
colorMode,
|
||||
colorAccessor: null,
|
||||
colors,
|
||||
resettingInterface: false
|
||||
};
|
||||
}
|
||||
case "set World to current selection": {
|
||||
const {
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
} = state;
|
||||
|
||||
/* Set viewable world to be the currently selected data */
|
||||
const world = World.createWorldFromCurrentSelection(
|
||||
action.universe,
|
||||
action.world,
|
||||
action.crossfilter
|
||||
);
|
||||
const colors = createColors(world, colorMode, colorAccessor);
|
||||
const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState(
|
||||
state,
|
||||
world
|
||||
);
|
||||
let crossfilter = new Crossfilter(world.obsAnnotations);
|
||||
crossfilter = World.createObsDimensions(crossfilter, world);
|
||||
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
|
||||
WorldUtil.clearCaches();
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: null,
|
||||
world,
|
||||
colors,
|
||||
categoricalSelectionState,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
case "expression load success": {
|
||||
const { world, universe } = state;
|
||||
let universeVarData = universe.varData;
|
||||
let worldVarData = world.varData;
|
||||
|
||||
// Load new expression data into the varData dataframes, if
|
||||
// not already present.
|
||||
_.forEach(action.expressionData, (val, key) => {
|
||||
// If not already in universe.varData, save entire expression column
|
||||
if (!universeVarData.hasCol(key)) {
|
||||
universeVarData = universeVarData.withCol(key, val);
|
||||
}
|
||||
|
||||
// If not already in world.varData, save sliced expression column
|
||||
if (!worldVarData.hasCol(key)) {
|
||||
// Slice if world !== universe, else just use whole column.
|
||||
// Use the obsAnnotation index as the cut key, as we keep
|
||||
// all world dataframes in sync.
|
||||
let worldValSlice = val;
|
||||
if (!World.worldEqUniverse(world, universe)) {
|
||||
worldValSlice = universeVarData
|
||||
.subset(world.obsAnnotations.rowIndex.keys(), [key], null)
|
||||
.icol(0)
|
||||
.asArray();
|
||||
}
|
||||
|
||||
// Now build world's varData dataframe
|
||||
worldVarData = worldVarData.withCol(
|
||||
key,
|
||||
worldValSlice,
|
||||
world.obsAnnotations.rowIndex
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune size of varData "cache" if getting out of hand....
|
||||
const { userDefinedGenes, diffexpGenes } = state;
|
||||
const allTheGenesWeNeed = _.uniq(
|
||||
[].concat(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
Object.keys(action.expressionData)
|
||||
)
|
||||
);
|
||||
universeVarData = ControlsHelpers.pruneVarDataCache(
|
||||
universeVarData,
|
||||
allTheGenesWeNeed
|
||||
);
|
||||
worldVarData = ControlsHelpers.pruneVarDataCache(
|
||||
worldVarData,
|
||||
allTheGenesWeNeed
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
universe: {
|
||||
...universe,
|
||||
varData: universeVarData
|
||||
},
|
||||
world: {
|
||||
...world,
|
||||
varData: worldVarData
|
||||
}
|
||||
error: null
|
||||
};
|
||||
}
|
||||
case "request user defined gene started": {
|
||||
@@ -269,108 +81,50 @@ const Controls = (
|
||||
};
|
||||
}
|
||||
case "request user defined gene success": {
|
||||
const { world, crossfilter: oldCrossfilter, userDefinedGenes } = state;
|
||||
const _userDefinedGenes = userDefinedGenes.slice();
|
||||
const gene = action.data.genes[0];
|
||||
|
||||
const crossfilter = oldCrossfilter.addDimension(
|
||||
userDefinedDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
const { userDefinedGenes } = state;
|
||||
const _userDefinedGenes = _.uniq(
|
||||
userDefinedGenes.concat(action.data.genes)
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
userDefinedGenes: _userDefinedGenes,
|
||||
userDefinedGenesLoading: false
|
||||
};
|
||||
}
|
||||
case "request differential expression success": {
|
||||
const { world, crossfilter: oldCrossfilter } = state;
|
||||
const { world } = prevSharedState;
|
||||
const _diffexpGenes = [];
|
||||
|
||||
action.data.forEach(d => {
|
||||
_diffexpGenes.push(world.varAnnotations.at(d[0], "name"));
|
||||
});
|
||||
|
||||
let crossfilter = oldCrossfilter;
|
||||
_.forEach(_diffexpGenes, gene => {
|
||||
crossfilter = crossfilter.addDimension(
|
||||
diffexpDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
diffexpGenes: _diffexpGenes
|
||||
};
|
||||
}
|
||||
case "clear differential expression": {
|
||||
const { world } = state;
|
||||
let { crossfilter } = state;
|
||||
_.forEach(action.diffExp, values => {
|
||||
const name = world.varAnnotations.at(values[0], "name");
|
||||
crossfilter = crossfilter.delDimension(diffexpDimensionName(name));
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
diffexpGenes: []
|
||||
};
|
||||
}
|
||||
case "user defined gene": {
|
||||
/*
|
||||
this could also live in expression success with a conditional,
|
||||
but that handles diffexp also
|
||||
*/
|
||||
const newUserDefinedGenes = state.userDefinedGenes.slice();
|
||||
newUserDefinedGenes.push(action.data);
|
||||
return {
|
||||
...state,
|
||||
userDefinedGenes: newUserDefinedGenes
|
||||
};
|
||||
}
|
||||
case "clear user defined gene": {
|
||||
const { userDefinedGenes, crossfilter: oldCrossfilter } = state;
|
||||
const { userDefinedGenes } = state;
|
||||
const newUserDefinedGenes = _.filter(
|
||||
userDefinedGenes,
|
||||
d => d !== action.data
|
||||
);
|
||||
const crossfilter = oldCrossfilter.delDimension(
|
||||
userDefinedDimensionName(action.data)
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
userDefinedGenes: newUserDefinedGenes
|
||||
};
|
||||
}
|
||||
case "clear all user defined genes": {
|
||||
const { userDefinedGenes } = state;
|
||||
let { crossfilter } = state;
|
||||
_.forEach(userDefinedGenes, gene => {
|
||||
crossfilter = crossfilter.delDimension(userDefinedDimensionName(gene));
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
userDefinedGenes: []
|
||||
};
|
||||
}
|
||||
case "reset colorscale": {
|
||||
const { world } = state;
|
||||
return {
|
||||
...state,
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
colors: createColors(world)
|
||||
};
|
||||
}
|
||||
case "expression load error":
|
||||
case "initial data load error": {
|
||||
return {
|
||||
@@ -383,69 +137,6 @@ const Controls = (
|
||||
/*******************************
|
||||
User Events
|
||||
*******************************/
|
||||
case "graph brush selection change": {
|
||||
const name = layoutDimensionName("XY");
|
||||
const [x0, y0] = action.brushCoords.northwest;
|
||||
const [x1, y1] = action.brushCoords.southeast;
|
||||
const crossfilter = state.crossfilter.select(name, {
|
||||
mode: "within-rect",
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
graphBrushSelection: action.brushCoords
|
||||
};
|
||||
}
|
||||
case "lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
const name = layoutDimensionName("XY");
|
||||
const crossfilter = state.crossfilter.select(name, { mode: "all" });
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
graphBrushSelection: null
|
||||
};
|
||||
}
|
||||
case "lasso selection": {
|
||||
const { polygon } = action;
|
||||
const name = layoutDimensionName("XY");
|
||||
const { crossfilter: oldCrossfilter } = state;
|
||||
let crossfilter;
|
||||
if (polygon.length < 3) {
|
||||
// single point or a line is not a polygon, and is therefore a deselect
|
||||
crossfilter = oldCrossfilter.select(name, { mode: "all" });
|
||||
} else {
|
||||
crossfilter = oldCrossfilter.select(name, {
|
||||
mode: "within-polygon",
|
||||
polygon
|
||||
});
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
case "continuous metadata histogram brush": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
let { crossfilter } = state;
|
||||
|
||||
// action.selection: metadata name being selected
|
||||
// action.range: filter range, or null if deselected
|
||||
if (!action.range) {
|
||||
crossfilter = crossfilter.select(name, { mode: "all" });
|
||||
} else {
|
||||
const [lo, hi] = action.range;
|
||||
crossfilter = crossfilter.select(name, { mode: "range", lo, hi });
|
||||
}
|
||||
return { ...state, crossfilter };
|
||||
}
|
||||
case "change opacity deselected cells in 2d graph background":
|
||||
return {
|
||||
...state,
|
||||
@@ -464,126 +155,6 @@ const Controls = (
|
||||
resettingInterface: true
|
||||
};
|
||||
}
|
||||
/*******************************
|
||||
Categorical metadata
|
||||
*******************************/
|
||||
case "categorical metadata filter select": {
|
||||
const newCategorySelected = Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = true;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
|
||||
// update the filter to match all selected options
|
||||
const cat = newCategoricalSelectionState[action.metadataField];
|
||||
const dName = obsAnnoDimensionName(action.metadataField);
|
||||
const crossfilter = state.crossfilter.select(dName, {
|
||||
mode: "exact",
|
||||
values: ControlsHelpers.selectedValuesForCategory(cat)
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter deselect": {
|
||||
const newCategorySelected = Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].categorySelected
|
||||
);
|
||||
newCategorySelected[action.categoryIndex] = false;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: newCategorySelected
|
||||
}
|
||||
};
|
||||
|
||||
// update the filter to match all selected options
|
||||
const cat = newCategoricalSelectionState[action.metadataField];
|
||||
const dName = obsAnnoDimensionName(action.metadataField);
|
||||
const crossfilter = state.crossfilter.select(dName, {
|
||||
mode: "exact",
|
||||
values: ControlsHelpers.selectedValuesForCategory(cat)
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter none of these": {
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state.categoricalSelectionState[action.metadataField]
|
||||
.categorySelected
|
||||
).fill(false)
|
||||
}
|
||||
};
|
||||
const dName = obsAnnoDimensionName(action.metadataField);
|
||||
const crossfilter = state.crossfilter.select(dName, {
|
||||
mode: "none"
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter all of these": {
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
categorySelected: Array.from(
|
||||
state.categoricalSelectionState[action.metadataField]
|
||||
.categorySelected
|
||||
).fill(true)
|
||||
}
|
||||
};
|
||||
const dName = obsAnnoDimensionName(action.metadataField);
|
||||
const crossfilter = state.crossfilter.select(dName, {
|
||||
mode: "all"
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
categoricalSelectionState: newCategoricalSelectionState,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
|
||||
/*******************************
|
||||
Color Scale
|
||||
*******************************/
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
const { world } = state;
|
||||
return {
|
||||
...state,
|
||||
colorMode: action.type,
|
||||
colorAccessor: action.colorAccessor,
|
||||
colors: createColors(world, action.type, action.colorAccessor)
|
||||
};
|
||||
}
|
||||
case "color by expression": {
|
||||
const { world } = state;
|
||||
return {
|
||||
...state,
|
||||
colorMode: action.type,
|
||||
colorAccessor: action.gene,
|
||||
colors: createColors(world, action.type, action.gene)
|
||||
};
|
||||
}
|
||||
|
||||
/*******************************
|
||||
Scatterplot
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import Crossfilter from "../util/typedCrossfilter";
|
||||
import { World, ControlsHelpers } from "../util/stateManager";
|
||||
import {
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName,
|
||||
makeContinuousDimensionName
|
||||
} from "../util/nameCreators";
|
||||
|
||||
const CrossfilterReducer = (
|
||||
state = null,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { world } = nextSharedState;
|
||||
const crossfilter = World.createObsDimensions(
|
||||
new Crossfilter(world.obsAnnotations),
|
||||
world
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "reset World to eq Universe": {
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
||||
const { world } = nextSharedState;
|
||||
const crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
prevSharedState.resetCache.crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "set World to current selection": {
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
||||
const { world } = nextSharedState;
|
||||
let crossfilter = new Crossfilter(world.obsAnnotations);
|
||||
crossfilter = World.createObsDimensions(crossfilter, world);
|
||||
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "request user defined gene success": {
|
||||
const { world } = prevSharedState;
|
||||
const gene = action.data.genes[0];
|
||||
return state.addDimension(
|
||||
userDefinedDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
);
|
||||
}
|
||||
|
||||
case "request differential expression success": {
|
||||
const { world } = prevSharedState;
|
||||
const genes = _.map(action.data, d =>
|
||||
world.varAnnotations.at(d[0], "name")
|
||||
);
|
||||
const crossfilter = _.reduce(
|
||||
genes,
|
||||
(xfltr, gene) =>
|
||||
xfltr.addDimension(
|
||||
diffexpDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
),
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "clear differential expression": {
|
||||
const { world } = prevSharedState;
|
||||
const crossfilter = _.reduce(
|
||||
action.diffExp,
|
||||
(xfltr, values) => {
|
||||
const name = world.varAnnotations.at(values[0], "name");
|
||||
return xfltr.delDimension(diffexpDimensionName(name));
|
||||
},
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "clear user defined gene": {
|
||||
return state.delDimension(userDefinedDimensionName(action.data));
|
||||
}
|
||||
|
||||
case "clear all user defined genes": {
|
||||
const { userDefinedGenes } = prevSharedState.controls;
|
||||
const crossfilter = _.reduce(
|
||||
userDefinedGenes,
|
||||
(xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)),
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "graph brush selection change": {
|
||||
const name = layoutDimensionName("XY");
|
||||
const [x0, y0] = action.brushCoords.northwest;
|
||||
const [x1, y1] = action.brushCoords.southeast;
|
||||
return state.select(name, {
|
||||
mode: "within-rect",
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1
|
||||
});
|
||||
}
|
||||
|
||||
case "lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
const name = layoutDimensionName("XY");
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
|
||||
case "lasso selection": {
|
||||
const { polygon } = action;
|
||||
const name = layoutDimensionName("XY");
|
||||
if (polygon.length < 3) {
|
||||
// single point or a line is not a polygon, and is therefore a deselect
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
return state.select(name, {
|
||||
mode: "within-polygon",
|
||||
polygon
|
||||
});
|
||||
}
|
||||
|
||||
case "continuous metadata histogram start":
|
||||
case "continuous metadata histogram brush":
|
||||
case "continuous metadata histogram end": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
// action.selection: metadata name being selected
|
||||
// action.range: filter range, or null if deselected
|
||||
if (!action.range) {
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
const [lo, hi] = action.range;
|
||||
const newState = state.select(name, { mode: "range", lo, hi });
|
||||
return newState;
|
||||
}
|
||||
|
||||
case "categorical metadata filter select":
|
||||
case "categorical metadata filter deselect": {
|
||||
const { categoricalSelection } = nextSharedState;
|
||||
const cat = categoricalSelection[action.metadataField];
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "exact",
|
||||
values: ControlsHelpers.selectedValuesForCategory(cat)
|
||||
});
|
||||
}
|
||||
|
||||
case "categorical metadata filter none of these": {
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "none"
|
||||
});
|
||||
}
|
||||
|
||||
case "categorical metadata filter all of these": {
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "all"
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default CrossfilterReducer;
|
||||
@@ -1,20 +1,86 @@
|
||||
// jshint esversion: 6
|
||||
import { combineReducers, createStore, applyMiddleware } from "redux";
|
||||
import { createStore, applyMiddleware } from "redux";
|
||||
import thunk from "redux-thunk";
|
||||
import { composeWithDevTools } from "redux-devtools-extension";
|
||||
|
||||
import cascadeReducers from "./cascade";
|
||||
import undoable from "./undoable";
|
||||
import config from "./config";
|
||||
import universe from "./universe";
|
||||
import world from "./world";
|
||||
import categoricalSelection from "./categoricalSelection";
|
||||
import crossfilter from "./crossfilter";
|
||||
import colors from "./colors";
|
||||
import differential from "./differential";
|
||||
import responsive from "./responsive";
|
||||
import controls from "./controls";
|
||||
import resetCache from "./resetCache";
|
||||
|
||||
const Reducer = combineReducers({
|
||||
config,
|
||||
responsive,
|
||||
controls,
|
||||
differential
|
||||
});
|
||||
const ignoredActions = new Set([
|
||||
// these actions will not affect history, ie, we will
|
||||
// not snapshot history upon these actions. These take
|
||||
// precedent over `clearHistoryUponActions`
|
||||
"url changed",
|
||||
"interface reset started",
|
||||
"initial data load start",
|
||||
"configuration load complete",
|
||||
"increment graph render counter",
|
||||
"window resize",
|
||||
|
||||
const store = createStore(Reducer, composeWithDevTools(applyMiddleware(thunk)));
|
||||
"lasso started",
|
||||
|
||||
"request differential expression success",
|
||||
|
||||
"expression load start",
|
||||
"expression load success",
|
||||
"expression load error",
|
||||
|
||||
"continuous metadata histogram brush",
|
||||
"continuous metadata histogram end",
|
||||
|
||||
"request user defined gene started",
|
||||
"request user defined gene success",
|
||||
"request user defined gene error",
|
||||
"bulk user defined gene complete",
|
||||
"single user defined gene complete"
|
||||
]);
|
||||
|
||||
const clearOnActions = new Set([
|
||||
// history will be cleared when these actions occur
|
||||
"initial data load complete (universe exists)",
|
||||
"reset World to eq Universe",
|
||||
"initial data load error"
|
||||
]);
|
||||
|
||||
/* configuration for the undoable meta reducer */
|
||||
const undoableConfig = {
|
||||
historyLimit: 50, // maximum history size
|
||||
skipActionFilter: (state, action) => ignoredActions.has(action.type),
|
||||
clearOnActionFilter: (state, action) => clearOnActions.has(action.type)
|
||||
};
|
||||
|
||||
const Reducer = undoable(
|
||||
cascadeReducers([
|
||||
["config", config],
|
||||
["universe", universe],
|
||||
["world", world],
|
||||
["categoricalSelection", categoricalSelection],
|
||||
["crossfilter", crossfilter],
|
||||
["colors", colors],
|
||||
["controls", controls],
|
||||
["differential", differential],
|
||||
["responsive", responsive],
|
||||
["resetCache", resetCache]
|
||||
]),
|
||||
[
|
||||
"world",
|
||||
"categoricalSelection",
|
||||
"crossfilter",
|
||||
"colors",
|
||||
"controls",
|
||||
"differential"
|
||||
],
|
||||
undoableConfig
|
||||
);
|
||||
|
||||
const store = createStore(Reducer, applyMiddleware(thunk));
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Reducer which caches derived state to be used in a reset
|
||||
*/
|
||||
|
||||
const ResetCacheReducer = (
|
||||
state = {
|
||||
world: null,
|
||||
crossfilter: null
|
||||
},
|
||||
action,
|
||||
nextSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { world, crossfilter } = nextSharedState;
|
||||
return {
|
||||
...state,
|
||||
world,
|
||||
crossfilter
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ResetCacheReducer;
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
A redo/undo meta reducer for Redux. Designed to work well with the cascadeReducer().
|
||||
|
||||
Requires three parameters:
|
||||
* reducer - a reducer, which MUST return an object as state.
|
||||
* undoableKeys - an array of object keys (strings). If any of these keys
|
||||
are in the object/state returned by the reducer, they will be treated as
|
||||
state to be made "undoable".
|
||||
* options - an optional object, which may contain the following parameters:
|
||||
* historyLimit: max number of historical states to remember (aka max undo depth)
|
||||
* skipActionFilter: filter function, (state, action) => bool. If it returns
|
||||
truthy, the current state will not be pushed onto the history stack.
|
||||
* clearOnActionFilter: filter function, (state, action) => bool. If it returns
|
||||
truthy, the history state will be cleared as part of handling this action.
|
||||
|
||||
skipActionFilter has precedence over clearOnActionFilter.
|
||||
|
||||
This meta reducer accepts three actions types:
|
||||
* @@undoable/undo - move back in history
|
||||
* @@undoable/redo - move forward in history
|
||||
* @@undoable/clear - clear history
|
||||
*/
|
||||
|
||||
const historyKeyPrefix = "@@undoable/";
|
||||
const pastKey = `${historyKeyPrefix}past`;
|
||||
const futureKey = `${historyKeyPrefix}future`;
|
||||
const defaultHistoryLimit = -100;
|
||||
|
||||
const Undoable = (reducer, undoableKeys, options = {}) => {
|
||||
let { historyLimit } = options;
|
||||
if (!historyLimit) historyLimit = defaultHistoryLimit;
|
||||
if (historyLimit > 0) historyLimit = -historyLimit;
|
||||
const skipActionFilter = options.skipActionFilter || (() => false);
|
||||
const clearOnActionFilter = options.clearOnActionFilter || (() => false);
|
||||
|
||||
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
|
||||
throw new Error("undoable keys array must be specified");
|
||||
const undoableKeysSet = new Set(undoableKeys);
|
||||
|
||||
function undo(currentState) {
|
||||
const past = currentState[pastKey];
|
||||
const future = currentState[futureKey];
|
||||
if (past.length === 0) return currentState;
|
||||
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||
undoableKeysSet.has(kv[0])
|
||||
);
|
||||
const newPast = [...past];
|
||||
const newState = newPast.pop();
|
||||
const newFuture = push(future, currentUndoableState);
|
||||
const nextState = {
|
||||
...currentState,
|
||||
...fromEntries(newState),
|
||||
[pastKey]: newPast,
|
||||
[futureKey]: newFuture
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function redo(currentState) {
|
||||
const past = currentState[pastKey] || [];
|
||||
const future = currentState[futureKey] || [];
|
||||
if (future.length === 0) return currentState;
|
||||
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||
undoableKeysSet.has(kv[0])
|
||||
);
|
||||
const newFuture = [...future];
|
||||
const newState = newFuture.pop();
|
||||
const newPast = push(past, currentUndoableState);
|
||||
const nextState = {
|
||||
...currentState,
|
||||
...fromEntries(newState),
|
||||
[pastKey]: newPast,
|
||||
[futureKey]: newFuture
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function clear(currentState) {
|
||||
return {
|
||||
...currentState,
|
||||
[pastKey]: [],
|
||||
[futureKey]: []
|
||||
};
|
||||
}
|
||||
|
||||
function skip(currentState, action) {
|
||||
const past = currentState[pastKey] || [];
|
||||
const res = reducer(currentState, action);
|
||||
return {
|
||||
...res,
|
||||
[pastKey]: past,
|
||||
[futureKey]: []
|
||||
};
|
||||
}
|
||||
|
||||
function save(currentState, action) {
|
||||
const past = currentState[pastKey] || [];
|
||||
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||
undoableKeysSet.has(kv[0])
|
||||
);
|
||||
const res = reducer(currentState, action);
|
||||
const newPast = push(past, currentUndoableState, historyLimit);
|
||||
const nextState = {
|
||||
...res,
|
||||
[pastKey]: newPast,
|
||||
[futureKey]: []
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
|
||||
return (
|
||||
currentState = {
|
||||
[pastKey]: [],
|
||||
[futureKey]: []
|
||||
},
|
||||
action
|
||||
) => {
|
||||
const aType = action.type;
|
||||
switch (aType) {
|
||||
case "@@undoable/undo": {
|
||||
return undo(currentState, action);
|
||||
}
|
||||
case "@@undoable/redo": {
|
||||
return redo(currentState, action);
|
||||
}
|
||||
case "@@undoable/clear": {
|
||||
return clear(currentState, action);
|
||||
}
|
||||
default: {
|
||||
if (skipActionFilter(currentState, action)) {
|
||||
return skip(currentState, action);
|
||||
}
|
||||
if (clearOnActionFilter(currentState, action)) {
|
||||
return clear(skip(currentState, action));
|
||||
}
|
||||
return save(currentState, action);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function push(arr, val, limit = undefined) {
|
||||
/*
|
||||
functional array push, with a max length limit to the new array.
|
||||
Like Array.push, except it returns new array and discards as needed
|
||||
to enforce the length limit.
|
||||
*/
|
||||
const narr = arr.slice(limit);
|
||||
narr.push(val);
|
||||
return narr;
|
||||
}
|
||||
|
||||
function fromEntries(arr) {
|
||||
/*
|
||||
Similar to Object.fromEntries, but only handles array.
|
||||
This could be replaced with the standard fucnction once it
|
||||
is widely available. As of 3/20/2019, it has not yet
|
||||
been released in the Chrome stable channel.
|
||||
*/
|
||||
const obj = {};
|
||||
for (let i = 0, l = arr.length; i < l; i += 1) {
|
||||
obj[arr[i][0]] = arr[i][1];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export default Undoable;
|
||||
@@ -0,0 +1,47 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import { ControlsHelpers } from "../util/stateManager";
|
||||
|
||||
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { universe } = action;
|
||||
return universe;
|
||||
}
|
||||
|
||||
case "expression load success": {
|
||||
let { varData } = state;
|
||||
|
||||
// Load new expression data into the varData dataframes, if
|
||||
// not already present.
|
||||
_.forEach(action.expressionData, (val, key) => {
|
||||
// If not already in universe.varData, save entire expression column
|
||||
if (!varData.hasCol(key)) {
|
||||
varData = varData.withCol(key, val);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune size of varData "cache" if getting out of hand....
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||
const allTheGenesWeNeed = _.uniq(
|
||||
[].concat(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
Object.keys(action.expressionData)
|
||||
)
|
||||
);
|
||||
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
|
||||
|
||||
return {
|
||||
...state,
|
||||
varData
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default Universe;
|
||||
@@ -0,0 +1,88 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import { World, ControlsHelpers } from "../util/stateManager";
|
||||
|
||||
const WorldReducer = (
|
||||
state = null,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
const { universe } = nextSharedState;
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
return world;
|
||||
}
|
||||
|
||||
case "reset World to eq Universe": {
|
||||
return prevSharedState.resetCache.world;
|
||||
}
|
||||
|
||||
case "set World to current selection": {
|
||||
/* Set viewable world to be the currently selected data */
|
||||
const world = World.createWorldFromCurrentSelection(
|
||||
action.universe,
|
||||
action.world,
|
||||
action.crossfilter
|
||||
);
|
||||
return world;
|
||||
}
|
||||
|
||||
case "expression load success": {
|
||||
const { universe } = nextSharedState;
|
||||
const universeVarData = universe.varData;
|
||||
let worldVarData = state.varData;
|
||||
|
||||
// Load new expression data into the varData dataframes, if
|
||||
// not already present.
|
||||
_.forEach(action.expressionData, (val, key) => {
|
||||
// If not already in world.varData, save sliced expression column
|
||||
if (!worldVarData.hasCol(key)) {
|
||||
// Slice if world !== universe, else just use whole column.
|
||||
// Use the obsAnnotation index as the cut key, as we keep
|
||||
// all world dataframes in sync.
|
||||
let worldValSlice = val;
|
||||
if (!World.worldEqUniverse(state, universe)) {
|
||||
worldValSlice = universeVarData
|
||||
.subset(state.obsAnnotations.rowIndex.keys(), [key], null)
|
||||
.icol(0)
|
||||
.asArray();
|
||||
}
|
||||
|
||||
// Now build world's varData dataframe
|
||||
worldVarData = worldVarData.withCol(
|
||||
key,
|
||||
worldValSlice,
|
||||
state.obsAnnotations.rowIndex
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune size of varData "cache" if getting out of hand....
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||
const allTheGenesWeNeed = _.uniq(
|
||||
[].concat(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
Object.keys(action.expressionData)
|
||||
)
|
||||
);
|
||||
worldVarData = ControlsHelpers.pruneVarDataCache(
|
||||
worldVarData,
|
||||
allTheGenesWeNeed
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
varData: worldVarData
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default WorldReducer;
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName
|
||||
} from "../nameCreators";
|
||||
import * as World from "./world";
|
||||
|
||||
/*
|
||||
Selection state for categoricals are tracked in an Object that
|
||||
@@ -55,7 +54,7 @@ function topNCategories(summary) {
|
||||
return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)];
|
||||
}
|
||||
|
||||
export function createCategoricalSelectionState(state, world) {
|
||||
export function createCategoricalSelection(maxCategoryItems, world) {
|
||||
const res = {};
|
||||
_.forEach(world.obsAnnotations.colIndex.keys(), key => {
|
||||
const summary = world.obsAnnotations.col(key).summarize();
|
||||
@@ -64,7 +63,7 @@ export function createCategoricalSelectionState(state, world) {
|
||||
const isSelectableCategory =
|
||||
!isColorField &&
|
||||
key !== "name" &&
|
||||
summary.categories.length < state.maxCategoryItems;
|
||||
summary.categories.length < maxCategoryItems;
|
||||
if (isSelectableCategory) {
|
||||
const [categoryValues, categoryCounts] = topNCategories(summary);
|
||||
const categoryIndices = new Map(categoryValues.map((v, i) => [v, i]));
|
||||
@@ -86,7 +85,7 @@ export function createCategoricalSelectionState(state, world) {
|
||||
}
|
||||
|
||||
/*
|
||||
given a categoricalSelectionState, return the list of all category values
|
||||
given a categoricalSelection, return the list of all category values
|
||||
where selection state is true (ie, they are selected).
|
||||
*/
|
||||
export function selectedValuesForCategory(categorySelectionState) {
|
||||
|
||||
Reference in New Issue
Block a user