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
@@ -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,