mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-05 22:48:11 +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:
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
|
||||
|
||||
Reference in New Issue
Block a user