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:
Bruce Martin
2019-03-22 14:53:48 -07:00
committed by GitHub
parent 9420abfacc
commit 7ef5203564
25 changed files with 1106 additions and 555 deletions
+48
View File
@@ -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
*/
+6 -6
View File
@@ -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 -13
View File
@@ -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={{
+14 -16
View File
@@ -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
+10 -14
View File
@@ -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) {
+7 -11
View File
@@ -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: "" });
+47 -11
View File
@@ -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,
+44
View File
@@ -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;
};
}
+105
View File
@@ -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;
+88
View File
@@ -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;
+10 -439
View File
@@ -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
+189
View File
@@ -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;
+76 -10
View File
@@ -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;
+28
View File
@@ -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;
+167
View File
@@ -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;
+47
View File
@@ -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;
+88
View File
@@ -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) {