Graph selection state management and history bug fixes (#679)

* save graph selection in redux state

* fix old graph brush select regressions

* refactor graph brush selection to work with undo/redo

* update tests to match new crossfilter spatial select API

* graph selection state now in redux

* remove dead code

* sync graph selection with redux state; improvements to undoable machinery

* fix regression in undoable

* differentiate graph selection cancel from deselect action

* simplify calculation

* remove debugging code

* fix responsive repaint bug in graph selection tool

* undoable debugging and code cleanliness

* undoable action filter state now merges, rather than replaces

* improve comments

* add debounce to undoable action filter; improve comments and debug sanity check code

* comments

* fix undoable bug with clear scatterplot actions

* disable undoable debug flag

* cleanup API and comments around statemachine

* add test id attribute to lasso

* add better error handling for gene fetch requests
This commit is contained in:
Bruce Martin
2019-04-08 15:53:12 -07:00
committed by GitHub
parent c9a8e3ea42
commit 7275d9d4dc
16 changed files with 1150 additions and 237 deletions
@@ -304,15 +304,15 @@ describe("ImmutableTypedCrossfilter", () => {
});
test.each([[0, 0, 1, 1], [0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]])(
"within-rect %d %d %d %d",
(x0, y0, x1, y1) => {
(minX, minY, maxX, maxY) => {
expect(
p
.select("coords", { mode: "within-rect", x0, y0, x1, y1 })
.select("coords", { mode: "within-rect", minX, minY, maxX, maxY })
.allSelected()
).toEqual(
_.filter(someData, d => {
const [x, y] = d.coords;
return x0 <= x && x < x1 && y0 <= y && y < y1;
return minX <= x && x < maxX && minY <= y && y < maxY;
})
);
}
+6
View File
@@ -302,6 +302,9 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
const resetInterface = () => (dispatch, getState) => {
const { universe } = getState();
dispatch({
type: "user reset start"
});
dispatch({
type: "clear all user defined genes"
});
@@ -321,6 +324,9 @@ const resetInterface = () => (dispatch, getState) => {
dispatch({
type: "increment graph render counter"
});
dispatch({
type: "user reset end"
});
};
export default {
@@ -124,7 +124,7 @@ class HistogramBrush extends React.Component {
const dX0 = Math.abs(x0 - selection[0]);
const dX1 = Math.abs(x1 - selection[1]);
/*
only update the brush if it is grossly incorrect,
only update the brush if it is grossly incorrect,
as defined by the moveDeltaThreshold
*/
if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) {
@@ -216,14 +216,13 @@ class HistogramBrush extends React.Component {
});
} else {
dispatch({
type: "continuous metadata histogram end",
type: "continuous metadata histogram cancel",
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: null
}
});
}
};
+23 -18
View File
@@ -119,8 +119,10 @@ class GeneExpression extends React.Component {
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: "single user defined gene complete" });
dispatch(actions.requestUserDefinedGene(gene)).then(
() => dispatch({ type: "single user defined gene complete" }),
() => dispatch({ type: "single user defined gene error" })
);
}
}
@@ -136,22 +138,25 @@ class GeneExpression extends React.Component {
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.");
} else if (userDefinedGenes.indexOf(gene) !== -1) {
keepAroundErrorToast("That gene already exists");
} else if (
world.varAnnotations.col("name").indexOf(gene) === undefined
) {
keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.`
);
} else {
dispatch(actions.requestUserDefinedGene(gene));
}
});
dispatch({ type: "bulk user defined gene complete" });
Promise.all(
genes.map(gene => {
if (gene.length === 0) {
return keepAroundErrorToast("Must enter a gene name.");
}
if (userDefinedGenes.indexOf(gene) !== -1) {
return keepAroundErrorToast("That gene already exists");
}
if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
return keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.`
);
}
return dispatch(actions.requestUserDefinedGene(gene));
})
).then(
() => dispatch({ type: "bulk user defined gene complete" }),
() => dispatch({ type: "bulk user defined gene error" })
);
}
this.setState({ bulkAdd: "" });
+268 -72
View File
@@ -40,15 +40,16 @@ import { World } from "../../util/stateManager";
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
celllist1: state.differential.celllist1,
celllist2: state.differential.celllist2,
library_versions: _.get(state.config, "library_versions", null),
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0
redoDisabled: state["@@undoable/future"].length === 0,
selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection
}))
class Graph extends React.Component {
constructor(props) {
super(props);
this.count = 0;
this.inverse = mat4.identity([]);
this.graphPaddingTop = 0;
this.graphPaddingBottom = 45;
this.graphPaddingRight = globals.leftSidebarWidth;
@@ -59,8 +60,9 @@ class Graph extends React.Component {
};
this.state = {
svg: null,
brush: null,
mode: "lasso"
tool: null,
container: null,
mode: "select"
};
}
@@ -102,9 +104,16 @@ class Graph extends React.Component {
});
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps, prevState) {
const { renderCache } = this;
const { world, crossfilter, colorRGB, responsive } = this.props;
const {
world,
crossfilter,
colorRGB,
responsive,
selectionTool,
currentSelection
} = this.props;
const {
reglRender,
mode,
@@ -116,6 +125,7 @@ class Graph extends React.Component {
sizeBuffer,
svg
} = this.state;
let stateChanges = {};
if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") {
reglRender.cancel();
@@ -148,9 +158,7 @@ class Graph extends React.Component {
dimension: 2
});
this.setState({
offset
});
stateChanges.offset = offset;
}
// Colors for each point - a cached value that only changes when
@@ -193,21 +201,58 @@ class Graph extends React.Component {
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width ||
/* first time */
(responsive.height && responsive.width && !svg)
(responsive.height && responsive.width && !svg) ||
selectionTool !== prevProps.selectionTool
) {
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#graphAttachPoint")
.selectAll("svg")
.remove();
const { svg: newSvg, brush } = setupSVGandBrushElements(
this.handleBrushSelectAction.bind(this),
this.handleBrushDeselectAction.bind(this),
let handleStart;
let handleDrag;
let handleEnd;
let handleCancel;
if (selectionTool === "brush") {
handleStart = this.handleBrushStartAction.bind(this);
handleDrag = this.handleBrushDragAction.bind(this);
handleEnd = this.handleBrushEndAction.bind(this);
} else {
handleStart = this.handleLassoStart.bind(this);
handleEnd = this.handleLassoEnd.bind(this);
handleCancel = this.handleLassoCancel.bind(this);
}
const { svg: newSvg, tool, container } = setupSVGandBrushElements(
selectionTool,
handleStart,
handleDrag,
handleEnd,
handleCancel,
responsive,
this.graphPaddingRight,
this.handleLassoStart.bind(this),
this.handleLassoEnd.bind(this)
this.graphPaddingRight
);
this.setState({ svg: newSvg, brush });
stateChanges = { ...stateChanges, svg: newSvg, tool, container };
}
/*
if the selection tool or state has changed, ensure that the selection
tool correctly reflects the underlying selection.
*/
if (
currentSelection !== prevProps.currentSelection ||
mode !== prevState.mode ||
stateChanges.svg
) {
const { tool, container, offset } = this.state;
this.selectionToolUpdate(
stateChanges.tool ? stateChanges.tool : tool,
stateChanges.container ? stateChanges.container : container,
stateChanges.offset ? stateChanges.offset : offset
);
}
if (Object.keys(stateChanges).length > 0) {
this.setState(stateChanges);
}
}
@@ -261,6 +306,86 @@ class Graph extends React.Component {
dispatch(actions.resetInterface());
};
brushToolUpdate(tool, container, offset) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { currentSelection } = this.props;
if (container) {
const toolCurrentSelection = d3.brushSelection(container.node());
if (currentSelection.mode === "within-rect") {
/*
if there is a selection, make sure the brush tool matches
*/
const screenCoords = [
this.mapPointToScreen(currentSelection.brushCoords.northwest, offset),
this.mapPointToScreen(currentSelection.brushCoords.southeast, offset)
];
if (!toolCurrentSelection) {
/* tool is not selected, so just move the brush */
container.call(tool.move, screenCoords);
} else {
/* there is an active selection and a brush - make sure they match */
/* this just sums the difference of each dimension, of each point */
let delta = 0;
for (let x = 0; x < 2; x += 1) {
for (let y = 0; y < 2; y += 1) {
delta += Math.abs(
screenCoords[x][y] - toolCurrentSelection[x][y]
);
}
}
if (delta > 0) {
container.call(tool.move, screenCoords);
}
}
} else if (toolCurrentSelection) {
/* no selection, so clear the brush tool if it is set */
container.call(tool.move, null);
}
}
}
lassoToolUpdate(tool, container, offset) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { currentSelection } = this.props;
if (currentSelection.mode === "within-polygon") {
/*
if there is a current selection, make sure the lasso tool matches
*/
const polygon = currentSelection.polygon.map(p =>
this.mapPointToScreen(p, offset)
);
tool.move(polygon);
} else {
tool.reset();
}
}
selectionToolUpdate(tool, container, offset) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { selectionTool } = this.props;
switch (selectionTool) {
case "brush":
this.brushToolUpdate(tool, container, offset);
break;
case "lasso":
this.lassoToolUpdate(tool, container, offset);
break;
default:
/* punt? */
break;
}
}
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
regl.clear({
depth: 1,
@@ -304,7 +429,11 @@ class Graph extends React.Component {
});
}
invertPoint(pin) {
mapScreenToPoint(pin) {
/*
Map an XY coordinates from screen domain to cell/point range,
accounting for current pan/zoom camera.
*/
const { responsive } = this.props;
const { regl, camera, offset } = this.state;
@@ -323,14 +452,45 @@ class Graph extends React.Component {
x * inverse[14] * aspect + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
}
handleBrushSelectAction() {
mapPointToScreen(xyCell, offset) {
/*
This conditional handles procedural brush deselect. Brush emits
an event on procedural deselect because it is move: null
Map an XY coordinate from cell/point domain to screen range. Inverse
of mapScreenToPoint()
*/
const { responsive } = this.props;
const { regl, camera } = this.state;
const gl = regl._gl;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// variable names are choosen to reflect inverse of those used
// in mapScreenToPoint().
const pout = [
(xyCell[0] - offset[0]) * 2 - 1,
(xyCell[1] - offset[1]) * 2 - 1
];
const x = (pout[0] - inverse[12]) / aspect / inverse[14];
const y = (pout[1] - inverse[13]) / inverse[14];
const pin = [
Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2),
Math.round(
-((y + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
)
];
return pin;
}
handleBrushDragAction() {
/*
event describing brush position:
@-------|
@@ -338,79 +498,105 @@ class Graph extends React.Component {
| |
|-------@
*/
// ignore programatically generated events
if (d3.event.sourceEvent === null || !d3.event.selection) return;
const { dispatch } = this.props;
const s = d3.event.selection;
const brushCoords = {
northwest: this.mapScreenToPoint([s[0][0], s[0][1]]),
southeast: this.mapScreenToPoint([s[1][0], s[1][1]])
};
dispatch({
type: "graph brush change",
brushCoords
});
}
handleBrushStartAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
const { dispatch } = this.props;
dispatch({ type: "graph brush start" });
}
handleBrushEndAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
/*
No idea why d3 event scope works like this
but apparently
it does
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
coordinates will be included if selection made, null
if selection cleared.
*/
const { dispatch } = this.props;
if (d3.event.sourceEvent !== null) {
const s = d3.event.selection;
const s = d3.event.selection;
if (s) {
const brushCoords = {
northwest: this.invertPoint([s[0][0], s[0][1]]),
southeast: this.invertPoint([s[1][0], s[1][1]])
northwest: this.mapScreenToPoint(s[0]),
southeast: this.mapScreenToPoint(s[1])
};
dispatch({
type: "graph brush selection change",
type: "graph brush end",
brushCoords
});
} else {
dispatch({
type: "graph brush deselect"
});
}
}
handleBrushDeselectAction() {
const { dispatch } = this.props;
const { svg, brush } = this.state;
if (d3.event && !d3.event.selection) {
dispatch({
type: "graph brush deselect"
});
}
if (!d3.event) {
/*
this line clears the brush procedurally, ie., zoom button clicked,
not a click away from brush on svg
*/
svg.select(".graph_brush").call(brush.move, null);
dispatch({
type: "graph brush deselect"
});
}
dispatch({
type: "graph brush deselect"
});
}
handleLassoStart() {
const { dispatch } = this.props;
// reset selected points when starting a new polygon
// making it easier for the user to make the next selection
dispatch({
type: "lasso started"
type: "graph lasso start"
});
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const minimumPolygoneArea = 10;
const minimumPolygonArea = 10;
const { dispatch } = this.props;
if (
polygon.length < 3 ||
Math.abs(d3.polygonArea(polygon)) < minimumPolygoneArea
Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea
) {
// if less than three points, or super small area, treat as a clear selection.
dispatch({ type: "lasso deselect" });
dispatch({ type: "graph lasso deselect" });
} else {
dispatch({
type: "lasso selection",
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
type: "graph lasso end",
polygon: polygon.map(xy => this.mapScreenToPoint(xy)) // transform the polygon
});
}
}
handleLassoCancel() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso cancel" });
}
handleLassoDeselectAction() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso deselect" });
}
handleDeselectAction() {
const { selectionTool } = this.props;
if (selectionTool === "brush") this.handleBrushDeselectAction();
if (selectionTool === "lasso") this.handleLassoDeselectAction();
}
handleOpacityRangeChange(e) {
const { dispatch } = this.props;
dispatch({
@@ -425,11 +611,24 @@ class Graph extends React.Component {
responsive,
crossfilter,
resettingInterface,
library_versions,
libraryVersions,
undoDisabled,
redoDisabled
redoDisabled,
selectionTool
} = this.props;
const { mode } = this.state;
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonClass;
if (selectionTool === "brush") {
selectionTooltip = "Brush selection";
selectionButtonClass = "bp3-icon-select";
} else {
selectionTooltip = "Lasso selection";
selectionButtonClass = "bp3-icon-polygon-filter";
}
return (
<div id="graphWrapper">
<div
@@ -487,16 +686,14 @@ class Graph extends React.Component {
</Tooltip>
<div>
<div className="bp3-button-group">
<Tooltip content="Lasso selection" position="left">
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
className="bp3-button bp3-icon-polygon-filter"
active={mode === "lasso"}
className={`bp3-button ${selectionButtonClass}`}
active={mode === "select"}
onClick={() => {
this.handleBrushDeselectAction();
// this.restartReglLoop();
this.setState({ mode: "lasso" });
this.setState({ mode: "select" });
}}
style={{
cursor: "pointer"
@@ -510,7 +707,6 @@ class Graph extends React.Component {
className="bp3-button bp3-icon-zoom-in"
active={mode === "zoom"}
onClick={() => {
this.handleBrushDeselectAction();
this.restartReglLoop();
this.setState({ mode: "zoom" });
}}
@@ -578,8 +774,8 @@ class Graph extends React.Component {
<MenuItem
target="_blank"
text={`cellxgene v${
library_versions && library_versions.cellxgene
? library_versions.cellxgene
libraryVersions && libraryVersions.cellxgene
? libraryVersions.cellxgene
: null
}`}
/>
@@ -609,7 +805,7 @@ class Graph extends React.Component {
>
<div
style={{
display: mode === "lasso" ? "inherit" : "none"
display: mode === "select" ? "inherit" : "none"
}}
id="graphAttachPoint"
/>
+19 -1
View File
@@ -3,7 +3,7 @@
import * as d3 from "d3";
const Lasso = () => {
const dispatch = d3.dispatch("start", "end");
const dispatch = d3.dispatch("start", "end", "cancel");
const polygonToPath = polygon =>
`M${polygon.map(d => d.join(",")).join("L")}`;
@@ -82,6 +82,7 @@ const Lasso = () => {
lassoPath.remove();
lassoPath = null;
lassoPolygon = null;
dispatch.call("cancel");
}
};
@@ -115,6 +116,23 @@ const Lasso = () => {
closePath = null;
}
};
lasso.move = polygon => {
if (polygon !== lassoPolygon || polygon.length !== lassoPolygon.length) {
lasso.reset();
lassoPolygon = polygon;
lassoPath = g
.append("path")
.attr("data-testid", "lasso-element")
.attr("fill", "#0bb")
.attr("fill-opacity", 0.1)
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3");
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
}
};
};
lasso.on = (type, callback) => {
+35 -24
View File
@@ -10,12 +10,13 @@ import Lasso from "./setupLasso";
******************************************/
export default (
handleBrushSelectAction,
handleBrushDeselectAction,
selectionToolType,
handleStartAction,
handleDragAction,
handleEndAction,
handleCancelAction,
responsive,
graphPaddingRight,
handleLassoStart,
handleLassoEnd
graphPaddingRight
) => {
const svg = d3
.select("#graphAttachPoint")
@@ -25,27 +26,37 @@ export default (
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`);
const brush = d3
.brush()
.extent([[0, 0], [responsive.width - graphPaddingRight, responsive.height]])
.on("brush", handleBrushSelectAction)
.on("end", handleBrushDeselectAction);
if (selectionToolType === "brush") {
const brush = d3
.brush()
.extent([
[0, 0],
[responsive.width - graphPaddingRight, responsive.height]
])
.on("start", handleStartAction)
.on("brush", handleDragAction)
// FYI, brush doesn't generate cancel
.on("end", handleEndAction);
const brushContainer = svg
.append("g")
.attr("class", "graph_brush")
.call(brush);
const brushContainer = svg
.append("g")
.attr("class", "graph_brush")
.call(brush);
const lassoInstance = Lasso()
.on("end", handleLassoEnd)
.on("start", handleLassoStart);
return { svg, container: brushContainer, tool: brush };
}
const lasso = svg.call(lassoInstance);
if (selectionToolType === "lasso") {
const lasso = Lasso()
.on("end", handleEndAction)
// FYI, Lasso doesn't generate drag
.on("start", handleStartAction)
.on("cancel", handleCancelAction);
return {
svg,
brushContainer,
brush,
lasso
};
const lassoContainer = svg.call(lasso);
return { svg, container: lassoContainer, tool: lasso };
}
throw new Error("unknown graph selection tool");
};
-26
View File
@@ -1,26 +0,0 @@
// jshint esversion: 6
// createExpressionsCountsMap () {
//
// const CHANGE_ME_MAGIC_GENE_INDEX = 5;
//
// const expressionsCountsMap = {};
//
// /* currently selected gene */
// expressionsCountsMap.geneName = this.state.expressions.data.genes[3];
//
// let maxExpressionValue = 0;
//
// /* create map of expressions for every cell */
// this.state.expressions.data.cells.map((c) => {
// /* cellname = 234 */
// expressionsCountsMap[c.cellname] = c["e"][CHANGE_ME_MAGIC_GENE_INDEX];
// /* collect the maximum value as we iterate */
// if (c["e"][CHANGE_ME_MAGIC_GENE_INDEX] > maxExpressionValue) {
// maxExpressionValue = c["e"][CHANGE_ME_MAGIC_GENE_INDEX]
// }
// })
//
// expressionsCountsMap.maxValue = maxExpressionValue;
//
// return expressionsCountsMap;
// }
+21 -22
View File
@@ -10,6 +10,8 @@ import {
makeContinuousDimensionName
} from "../util/nameCreators";
const XYDimName = layoutDimensionName("XY");
const CrossfilterReducer = (
state = null,
action,
@@ -109,40 +111,37 @@ const CrossfilterReducer = (
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, {
case "graph brush end":
case "graph brush change": {
const [minX, maxY] = action.brushCoords.northwest;
const [maxX, minY] = action.brushCoords.southeast;
return state.select(XYDimName, {
mode: "within-rect",
x0,
y0,
x1,
y1
minX,
minY,
maxX,
maxY
});
}
case "lasso deselect":
case "graph brush deselect": {
const name = layoutDimensionName("XY");
return state.select(name, { mode: "all" });
}
case "lasso selection": {
case "graph lasso end": {
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, {
return state.select(XYDimName, {
mode: "within-polygon",
polygon
});
}
case "graph lasso cancel":
case "graph brush cancel":
case "graph lasso deselect":
case "graph brush deselect": {
return state.select(XYDimName, { mode: "all" });
}
case "continuous metadata histogram start":
case "continuous metadata histogram brush":
case "continuous metadata histogram cancel":
case "continuous metadata histogram end": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
+59
View File
@@ -0,0 +1,59 @@
const GraphSelection = (
state = {
tool: "lasso", // what selection tool mode (lasso, brush, ...)
selection: { mode: "all" } // current selection, which is tool specific
},
action
) => {
switch (action.type) {
case "reset World to eq Universe": {
return {
...state,
selection: {
mode: "all"
}
};
}
case "graph brush end":
case "graph brush change": {
const { brushCoords } = action;
return {
...state,
selection: {
mode: "within-rect",
brushCoords
}
};
}
case "graph lasso end": {
const { polygon } = action;
return {
...state,
selection: {
mode: "within-polygon",
polygon
}
};
}
case "graph lasso cancel":
case "graph brush cancel":
case "graph lasso deselect":
case "graph brush deselect": {
return {
...state,
selection: {
mode: "all"
}
};
}
default: {
return state;
}
}
};
export default GraphSelection;
+4 -42
View File
@@ -8,6 +8,7 @@ import universe from "./universe";
import world from "./world";
import categoricalSelection from "./categoricalSelection";
import continuousSelection from "./continuousSelection";
import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter";
import colors from "./colors";
import differential from "./differential";
@@ -15,48 +16,7 @@ import responsive from "./responsive";
import controls from "./controls";
import resetCache from "./resetCache";
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",
"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)
};
import undoableConfig from "./undoableConfig";
const Reducer = undoable(
cascadeReducers([
@@ -65,6 +25,7 @@ const Reducer = undoable(
["world", world],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
["crossfilter", crossfilter],
["colors", colors],
["controls", controls],
@@ -76,6 +37,7 @@ const Reducer = undoable(
"world",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"colors",
"controls",
+155 -22
View File
@@ -8,35 +8,73 @@ Requires three parameters:
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.
* actionFilter: filter function, (state, action, filterState) => value.
See below for details.
* debug: if truish, will print helpful log messages about history manipulation
This meta reducer accepts three actions types:
* @@undoable/undo - move back in history
* @@undoable/redo - move forward in history
* @@undoable/clear - clear history
---
Action filter - controls the undoable reducer side-effects. If not
specified, the action filter defaults to "save", ie, pushes a redo
point upon each action.
The action filter callback has access to the current action, the entire
undoable reducer state, and any state it wants to manage ("filterState").
This filter state will be passed to each action filter call, and any
value returned (via @@undoable/filterState field described below) will be
MERGED into the current filter state.
An object must be returned (the "undoable action"), indicating desired
history state processing. The undoable action object contents, by key:
@@undoable/filterAction: required. Can be one of:
"skip" - reduce the current action, but no other side effects.
Same as returning false.
"clear" - reduce the current action, and clear history state.
"save" - push the previous state onto the history stack (ie,
before reducing the action)
"stashPending" - reduce action, save state as pending. Does not
not commit it to history. Along with cancelPending and applyPending,
can be used to delay commit of history (eg, for multi-action
groupings, asynch operations, etc).
"cancelPending" - reduce action, cancel any pending state save.
"applyPending" - commit any pending state to the history stack,
then reduce action.
@@undoable/filterState: optional. If this value is set, it will be
MERGED into the current filter state. The value and semantics of any
filter state are entirely at the discretion of the action filter.
*/
const historyKeyPrefix = "@@undoable/";
const pastKey = `${historyKeyPrefix}past`;
const futureKey = `${historyKeyPrefix}future`;
const filterStateKey = `${historyKeyPrefix}filterState`;
const filterActionKey = `${historyKeyPrefix}filterAction`;
const pendingKey = `${historyKeyPrefix}pending`;
const defaultHistoryLimit = -100;
const Undoable = (reducer, undoableKeys, options = {}) => {
const { debug } = options;
let { historyLimit } = options;
if (!historyLimit) historyLimit = defaultHistoryLimit;
if (historyLimit > 0) historyLimit = -historyLimit;
const skipActionFilter = options.skipActionFilter || (() => false);
const clearOnActionFilter = options.clearOnActionFilter || (() => false);
const actionFilter =
options.actionFilter || (() => ({ [filterActionKey]: "save" }));
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
throw new Error("undoable keys array must be specified");
const undoableKeysSet = new Set(undoableKeys);
/*
Undo the current to previous history
*/
function undo(currentState) {
const past = currentState[pastKey];
const future = currentState[futureKey];
@@ -51,11 +89,15 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Replay future, previously undone.
*/
function redo(currentState) {
const past = currentState[pastKey] || [];
const future = currentState[futureKey] || [];
@@ -70,30 +112,45 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Clear the history state. No side-effects on current state.
*/
function clear(currentState) {
return {
...currentState,
[pastKey]: [],
[futureKey]: []
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
};
}
function skip(currentState, action) {
/*
Reduce current action, with no history side-effects
*/
function skip(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const pending = currentState[pendingKey];
const res = reducer(currentState, action);
return {
...res,
[pastKey]: past,
[futureKey]: []
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: pending
};
}
function save(currentState, action) {
/*
Save current state in the history, then reduce action.
*/
function save(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
@@ -103,7 +160,48 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
const nextState = {
...res,
[pastKey]: newPast,
[futureKey]: []
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: null
};
return nextState;
}
/*
Save current state as pending history change. No other side effects.
*/
function stashPending(currentState) {
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
return {
...currentState,
[pendingKey]: currentUndoableState
};
}
/*
Cancel pending history state change. No other side effects.
*/
function cancelPending(currentState) {
return {
...currentState,
[pendingKey]: null
};
}
/*
Push pending state onto the history stack
*/
function applyPending(currentState) {
const past = currentState[pastKey] || [];
const pendingState = currentState[pendingKey];
const newPast = push(past, pendingState, historyLimit);
const nextState = {
...currentState,
[pastKey]: newPast,
[futureKey]: [],
[pendingKey]: null
};
return nextState;
}
@@ -111,7 +209,9 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
return (
currentState = {
[pastKey]: [],
[futureKey]: []
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
},
action
) => {
@@ -120,20 +220,53 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
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);
const currentFilterState = currentState[filterStateKey];
const actionFilterResp = actionFilter(
currentState,
action,
currentFilterState
);
const {
[filterActionKey]: filterAction,
[filterStateKey]: filterStateUpdate
} = actionFilterResp;
const nextFilterState = { ...currentFilterState, ...filterStateUpdate };
switch (filterAction) {
case "clear":
if (debug) console.log("---- CLEAR HISTO", action.type);
return clear(skip(currentState, action, nextFilterState));
case "save":
if (debug) console.log("---- SAVE HISTO", action.type);
return save(currentState, action, nextFilterState);
case "stashPending":
if (debug) console.log("---- STASH PENDING", action.type);
return skip(stashPending(currentState), action, nextFilterState);
case "cancelPending":
if (debug) console.log("---- CANCEL PENDING", action.type);
return skip(cancelPending(currentState), action, nextFilterState);
case "applyPending":
if (debug) console.log("---- APPLY PENDING", action.type);
return skip(applyPending(currentState), action, nextFilterState);
case "skip":
default:
return skip(currentState, action, nextFilterState);
}
if (clearOnActionFilter(currentState, action)) {
return clear(skip(currentState, action));
}
return save(currentState, action);
}
}
};
+250
View File
@@ -0,0 +1,250 @@
import StateMachine from "../util/statemachine";
import createFsmTransitions from "./undoableFsm";
const actionKey = "@@undoable/filterAction";
const stateKey = "@@undoable/filterState";
/*
these actions will not affect history
*/
const skipOnActions = new Set([
"url changed",
"interface reset started",
"initial data load start",
"configuration load complete",
"increment graph render counter",
"window resize",
"user reset start",
"reset colorscale",
"graph brush change",
"continuous metadata histogram brush",
"expression load start",
"expression load success",
"expression load error",
"request user defined gene started",
"request user defined gene success",
"clear all user defined genes",
"get single gene expression for coloring started",
"get single gene expression for coloring error"
]);
/*
identical, repeated occurances of these action types will be debounced.
Entire action must be identical (all keys).
*/
const debounceOnActions = new Set([
"color by categorical metadata",
"color by continuous metadata",
"color by expression"
]);
/*
history will be cleared when these actions occur
*/
const clearOnActions = new Set([
"initial data load complete (universe exists)",
"reset World to eq Universe",
"initial data load error",
"user reset end"
]);
/*
An immediate history save will be done for these
*/
const saveOnActions = new Set([
"categorical metadata filter select",
"categorical metadata filter deselect",
"categorical metadata filter all of these",
"categorical metadata none of these",
"color by categorical metadata",
"color by continuous metadata",
"color by expression",
"set scatterplot x",
"set scatterplot y",
"store current cell selection as differential set 1",
"store current cell selection as differential set 2",
"set World to current selection"
]);
/**
StateMachine - processing complex action handling - see FSM graph for
actual structure, in undoableFsm.js
**/
/*
Default FSM actions. Used to side-effect transitions in the graph.
See graph definition for the transitions that use each.
Signature: (fsm, transition, reducerState, reducerAction) => undoableAction
*/
const stashPending = fsm => ({
[actionKey]: "stashPending",
[stateKey]: { fsm }
});
const cancelPending = () => ({
[actionKey]: "cancelPending",
[stateKey]: { fsm: null }
});
const applyPending = () => ({
[actionKey]: "applyPending",
[stateKey]: { fsm: null }
});
const skip = fsm => ({ [actionKey]: "skip", [stateKey]: { fsm } });
const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } });
const save = fsm => ({ [actionKey]: "save", [stateKey]: { fsm } });
/*
Error handler for state transitions that are unexpected. Called by
StateMachine when it doesn't know what to do.
Signature: (fsm, event, from) => undoableAction
*/
const onFsmError = (fsm, name, from) => {
console.error("FSM error - unexpected history state", fsm, name, from);
// In production, try to recover gracefully if we have unexpected state
return clear(fsm);
};
/*
Definition of the transition graph mapping action types to history side effects.
*/
const fsmTransitions = createFsmTransitions(
stashPending,
cancelPending,
applyPending,
skip,
clear,
save
);
/* State machine we clone whenever we need to run it */
const seedFsm = new StateMachine("init", fsmTransitions, onFsmError);
/*
See undoable.js for description action filter interface description.
Basic approach:
* trivial handlers for skip, clear & save cases to keep config simple.
* only implement complex state machines where absolutely required (eg,
multi-event seleciton and the like)
*/
const actionFilter = debug => (state, action, prevFilterState) => {
const actionType = action.type;
const filterState = {
...prevFilterState,
prevAction: action
};
if (skipOnActions.has(actionType)) {
return { [actionKey]: "skip", [stateKey]: filterState };
}
if (
debounceOnActions.has(actionType) &&
shallowObjectEq(action, prevFilterState.prevAction)
) {
return { [actionKey]: "skip", [stateKey]: filterState };
}
if (clearOnActions.has(actionType)) {
return { [actionKey]: "clear", [stateKey]: filterState };
}
if (saveOnActions.has(actionType)) {
return { [actionKey]: "save", [stateKey]: filterState };
}
/*
Else, something more complex OR unknown to us....
*/
if (seedFsm.events.has(actionType)) {
let { fsm } = filterState;
if (!fsm) {
/* no active FSM, so create one in init state */
fsm = seedFsm.clone("init");
}
return fsm.next(action.type, { state, action });
}
/* else, we have no idea what this is - skip it */
if (debug) console.log("**** ACTION FILTER EVENT HANDLER MISS", actionType);
return { [actionKey]: "skip", [stateKey]: filterState };
};
/*
return true if objA and objB are ===, OR if:
- are both objects and not null
- have same own properties
- all values are strict equal (===)
*/
function shallowObjectEq(objA, objB) {
if (objA === objB) return true;
if (!objA || !objB) return false;
if (!shallowArrayEq(Object.keys(objA), Object.keys(objB))) return false;
if (!shallowArrayEq(Object.values(objA), Object.values(objB))) return false;
return true;
}
/*
return true if arrA and arrB contain the same strict-equal values,
in the same order.
*/
function shallowArrayEq(arrA, arrB) {
if (arrA.length !== arrB.length) return false;
for (let i = 0, l = arrA.length; i < l; i += 1) {
if (arrA[i] !== arrB[i]) return false;
}
return true;
}
/* configuration for the undoable meta reducer */
const debug = false; // set truish for undoble debugging
const undoableConfig = {
debug,
historyLimit: 50, // maximum history size
actionFilter: actionFilter(debug)
};
/*
this code is strictly for sanity checking configuration, and is only
enabled when we are debugging the undoable configuration (ie, debug === true).
*/
if (debug) {
/*
Confirm no intersection between the various trivial rejection action filters
*/
if (
new Set([...skipOnActions].filter(x => clearOnActions.has(x))).size > 0 ||
new Set([...skipOnActions].filter(x => saveOnActions.has(x))).size > 0 ||
new Set([...clearOnActions].filter(x => saveOnActions.has(x))).size > 0
) {
console.error(
"Undoable misconfiguration - action filters have redundant events"
);
}
/*
Confirm that no FSM events are blocked by a trivial rejection filter.
If this occurs, the FSM can't ever see the events needed to process
state transitions.
*/
const trivialFilters = new Set([
...skipOnActions,
...clearOnActions,
...saveOnActions
]);
const trivialOverlapWithFsm = new Set(
[...trivialFilters].filter(x => seedFsm.events.has(x))
);
if (trivialOverlapWithFsm.size > 0) {
console.error(
"Undoable misconfiguration - trivival action filter blocking FSM filter",
[...trivialOverlapWithFsm]
);
}
}
export default undoableConfig;
+206
View File
@@ -0,0 +1,206 @@
/*
State transition graph for complex action/history interactions.
Assumed configuration from undoableConfig:
* By convention, "init" is used as the start state for all, and "done"
as the final state.
* Unexpected states will result in an error, plus a clear and cancelPending
side-effect.
TODO: is is possible there is a more concise format for this, as it is
a fairly repetitive pattern.
These events are largely one of two types:
a) async operations or multi-event options that should only be committed
upon some success criteria, otherwise cancelled.
b) compound actions that should be collapsed into a single history change.
*/
const createFsmTransitions = (
stashPending,
cancelPending,
applyPending,
skip,
clear,
save
) => {
return [
/* graph selection brushing */
{
event: "graph brush start",
from: "init",
to: "graph brush in progress",
action: stashPending
},
{
event: "graph brush cancel",
from: "graph brush in progress",
to: "done",
action: applyPending
},
{
event: "graph brush deselect",
from: "graph brush in progress",
to: "done",
/* if current selection is all, cancelPending. Else, applyPending */
action: (fsm, transition, data) =>
data.state.graphSelection.selection.mode === "all"
? cancelPending()
: applyPending()
},
{
event: "graph brush end",
from: "graph brush in progress",
to: "done",
action: applyPending
},
/* graph selection lasso */
{
event: "graph lasso start",
from: "init",
to: "graph lasso in progress",
action: stashPending
},
{
event: "graph lasso cancel",
from: "graph lasso in progress",
to: "done",
action: applyPending
},
{
event: "graph lasso deselect",
from: "graph lasso in progress",
to: "done",
/* if current selection is all, cancelPending. Else, applyPending */
action: (fsm, transition, data) =>
data.state.graphSelection.selection.mode === "all"
? cancelPending()
: applyPending()
},
{
event: "graph lasso end",
from: "graph lasso in progress",
to: "done",
action: applyPending
},
/* Continuous metadata histogram brush selection */
{
event: "continuous metadata histogram start",
from: "init",
to: "continuous histo select in progress",
action: stashPending
},
{
event: "continuous metadata histogram cancel",
from: "continuous histo select in progress",
to: "done",
action: cancelPending
},
{
event: "continuous metadata histogram end",
from: "continuous histo select in progress",
to: "done",
action: applyPending
},
/* Single gene request by user */
{
event: "single user defined gene start",
from: "init",
to: "single user gene request in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "single user gene request in progress",
to: "single user gene error in progress",
action: skip
},
{
event: "single user defined gene error",
from: "single user gene error in progress",
to: "done",
action: cancelPending
},
{
event: "single user defined gene complete",
from: "single user gene request in progress",
to: "done",
action: applyPending
},
/* Bulk gene request by user */
{
event: "bulk user defined gene start",
from: "init",
to: "bulk user gene request in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "bulk user gene request in progress",
to: "bulk user gene request error in progress",
action: skip
},
{
event: "bulk user defined gene error",
from: "bulk user gene request error in progress",
to: "done",
action: cancelPending
},
{
event: "bulk user defined gene complete",
from: "bulk user gene request in progress",
to: "done",
action: applyPending
},
/* Compute Differential Expression button user action */
{
event: "request differential expression started",
from: "init",
to: "diffexp in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "diffexp in progress",
to: "done",
action: cancelPending
},
{
event: "request differential expression success",
from: "diffexp in progress",
to: "done",
action: applyPending
},
/* Clear Differential Expression button user action */
{
event: "clear differential expression",
from: "init",
to: "CDE Button in progress",
action: stashPending
},
{
event: "clear scatterplot",
from: "CDE Button in progress",
to: "done",
action: applyPending
},
/* clear scatter plot button (eg, on scatterplot view) */
{
event: "clear scatterplot",
from: "init",
to: "done",
action: save
}
];
};
export default createFsmTransitions;
+95
View File
@@ -0,0 +1,95 @@
/*
Very simple FSM for use in reducer, etc.
To create a state machine:
new StateMachine(initialState, transitions, onErrorCallback) -> statemachine
Where:
* initialState - a caller-specified value that represents the initial state of
the FSM.
* transitions - an array of objects, representing FSM transitions (graph edges),
having the form:
{
to: state_name_transitioning_to,
from: state_name_transitioning_from,
event: value_that_will_cause_transition,
action: optional_callback_upon_transition
}
The transition will be provided to the action callback, so other data
may be stored in the transition object for use by the action callback.
* onErrorCallback - a callback function called if the FSM receives an event
for which it has no defined transition.
Interface:
* states - property containing the state names. A Set(), contianing the
union of to: and from: values.
* events - property containing all of the accepted event values. Set().
* graph - a Map of Maps, organized as graph[eventValue][fromStateValue]
* clone() - clone the entire statemachine.
* next(eventValue) - drive the FSM to the next state. If the event
matches a transition with a defined action, the action callback is
called, and the action return value is returned by next(). If no
transition is defined, onErrorCallback is called.
Example:
const transitions = [
{ from: "A", to: "B", event: "yo", action: () => 42 }
];
const fsm = new StateMachine("A", transitions, () => { throw new Error("oops") });
fsm.next("yo"); // returns 42
*/
export default class StateMachine {
constructor(initState, transitions, onError) {
this.onError = onError || (() => undefined);
this.state = initState;
// all states
this.states = new Set(
transitions.reduce((names, tsn) => {
names.push(tsn.from);
names.push(tsn.to);
return names;
}, [])
);
// all transition names (aka events)
this.events = new Set(transitions.map(tsn => tsn.event));
// the transition graph.
// graph[event][from] -> transition
this.graph = transitions.reduce((graph, tsn) => {
const { event, from } = tsn;
if (!graph.has(event)) graph.set(event, new Map());
const tsnMap = graph.get(event);
tsnMap.set(from, tsn);
return graph;
}, new Map());
}
clone(initState) {
const fsm = new StateMachine(initState, []);
fsm.onError = this.onError;
fsm.states = this.states;
fsm.events = this.events;
fsm.graph = this.graph;
return fsm;
}
next(event, data) {
const { graph, state } = this;
const tsnMap = graph.get(event);
if (!tsnMap) return this.onError(this, event, state, undefined);
const transition = tsnMap.get(state);
if (!transition) return this.onError(this, event, state, undefined);
this.state = transition.to;
return transition.action
? transition.action(this, transition, data)
: undefined;
}
}
@@ -477,16 +477,16 @@ class ImmutableSpatialDimension extends _ImmutableBaseDimension {
selectWithinRect(spec) {
/*
{ mode: "within-rect", x0: 1, y0: 0, x1: 3, y1: 9 }
{ mode: "within-rect", minX: 1, minY: 0, maxX: 3, maxY: 9 }
*/
const { x0, y0, x1, y1 } = spec;
const { minX, minY, maxX, maxY } = spec;
const { X, Y } = this;
const ranges = [];
let start = -1;
for (let i = 0, l = X.length; i < l; i += 1) {
const x = X[i];
const y = Y[i];
const inside = x0 <= x && x < x1 && y0 <= y && y < y1;
const inside = minX <= x && x < maxX && minY <= y && y < maxY;
if (inside && start === -1) start = i;
if (!inside && start !== -1) {
ranges.push([start, i]);