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
+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;