mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-08 01:08:12 +08:00
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:
@@ -304,15 +304,15 @@ describe("ImmutableTypedCrossfilter", () => {
|
|||||||
});
|
});
|
||||||
test.each([[0, 0, 1, 1], [0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]])(
|
test.each([[0, 0, 1, 1], [0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]])(
|
||||||
"within-rect %d %d %d %d",
|
"within-rect %d %d %d %d",
|
||||||
(x0, y0, x1, y1) => {
|
(minX, minY, maxX, maxY) => {
|
||||||
expect(
|
expect(
|
||||||
p
|
p
|
||||||
.select("coords", { mode: "within-rect", x0, y0, x1, y1 })
|
.select("coords", { mode: "within-rect", minX, minY, maxX, maxY })
|
||||||
.allSelected()
|
.allSelected()
|
||||||
).toEqual(
|
).toEqual(
|
||||||
_.filter(someData, d => {
|
_.filter(someData, d => {
|
||||||
const [x, y] = d.coords;
|
const [x, y] = d.coords;
|
||||||
return x0 <= x && x < x1 && y0 <= y && y < y1;
|
return minX <= x && x < maxX && minY <= y && y < maxY;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,9 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
|||||||
const resetInterface = () => (dispatch, getState) => {
|
const resetInterface = () => (dispatch, getState) => {
|
||||||
const { universe } = getState();
|
const { universe } = getState();
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "user reset start"
|
||||||
|
});
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "clear all user defined genes"
|
type: "clear all user defined genes"
|
||||||
});
|
});
|
||||||
@@ -321,6 +324,9 @@ const resetInterface = () => (dispatch, getState) => {
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: "increment graph render counter"
|
type: "increment graph render counter"
|
||||||
});
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "user reset end"
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -216,14 +216,13 @@ class HistogramBrush extends React.Component {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "continuous metadata histogram end",
|
type: "continuous metadata histogram cancel",
|
||||||
selection: field,
|
selection: field,
|
||||||
continuousNamespace: {
|
continuousNamespace: {
|
||||||
isObs,
|
isObs,
|
||||||
isUserDefined,
|
isUserDefined,
|
||||||
isDiffExp
|
isDiffExp
|
||||||
},
|
}
|
||||||
range: null
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,8 +119,10 @@ class GeneExpression extends React.Component {
|
|||||||
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: "single user defined gene start" });
|
dispatch({ type: "single user defined gene start" });
|
||||||
dispatch(actions.requestUserDefinedGene(gene));
|
dispatch(actions.requestUserDefinedGene(gene)).then(
|
||||||
dispatch({ type: "single user defined gene complete" });
|
() => 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(/[ ,]+/)), "");
|
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||||
|
|
||||||
dispatch({ type: "bulk user defined gene start" });
|
dispatch({ type: "bulk user defined gene start" });
|
||||||
genes.forEach(gene => {
|
Promise.all(
|
||||||
if (gene.length === 0) {
|
genes.map(gene => {
|
||||||
keepAroundErrorToast("Must enter a gene name.");
|
if (gene.length === 0) {
|
||||||
} else if (userDefinedGenes.indexOf(gene) !== -1) {
|
return keepAroundErrorToast("Must enter a gene name.");
|
||||||
keepAroundErrorToast("That gene already exists");
|
}
|
||||||
} else if (
|
if (userDefinedGenes.indexOf(gene) !== -1) {
|
||||||
world.varAnnotations.col("name").indexOf(gene) === undefined
|
return keepAroundErrorToast("That gene already exists");
|
||||||
) {
|
}
|
||||||
keepAroundErrorToast(
|
if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
|
||||||
`${gene} doesn't appear to be a valid gene name.`
|
return keepAroundErrorToast(
|
||||||
);
|
`${gene} doesn't appear to be a valid gene name.`
|
||||||
} else {
|
);
|
||||||
dispatch(actions.requestUserDefinedGene(gene));
|
}
|
||||||
}
|
return dispatch(actions.requestUserDefinedGene(gene));
|
||||||
});
|
})
|
||||||
dispatch({ type: "bulk user defined gene complete" });
|
).then(
|
||||||
|
() => dispatch({ type: "bulk user defined gene complete" }),
|
||||||
|
() => dispatch({ type: "bulk user defined gene error" })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ bulkAdd: "" });
|
this.setState({ bulkAdd: "" });
|
||||||
|
|||||||
@@ -40,15 +40,16 @@ import { World } from "../../util/stateManager";
|
|||||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||||
celllist1: state.differential.celllist1,
|
celllist1: state.differential.celllist1,
|
||||||
celllist2: state.differential.celllist2,
|
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,
|
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 {
|
class Graph extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.count = 0;
|
this.count = 0;
|
||||||
this.inverse = mat4.identity([]);
|
|
||||||
this.graphPaddingTop = 0;
|
this.graphPaddingTop = 0;
|
||||||
this.graphPaddingBottom = 45;
|
this.graphPaddingBottom = 45;
|
||||||
this.graphPaddingRight = globals.leftSidebarWidth;
|
this.graphPaddingRight = globals.leftSidebarWidth;
|
||||||
@@ -59,8 +60,9 @@ class Graph extends React.Component {
|
|||||||
};
|
};
|
||||||
this.state = {
|
this.state = {
|
||||||
svg: null,
|
svg: null,
|
||||||
brush: null,
|
tool: null,
|
||||||
mode: "lasso"
|
container: null,
|
||||||
|
mode: "select"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +104,16 @@ class Graph extends React.Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
const { renderCache } = this;
|
const { renderCache } = this;
|
||||||
const { world, crossfilter, colorRGB, responsive } = this.props;
|
const {
|
||||||
|
world,
|
||||||
|
crossfilter,
|
||||||
|
colorRGB,
|
||||||
|
responsive,
|
||||||
|
selectionTool,
|
||||||
|
currentSelection
|
||||||
|
} = this.props;
|
||||||
const {
|
const {
|
||||||
reglRender,
|
reglRender,
|
||||||
mode,
|
mode,
|
||||||
@@ -116,6 +125,7 @@ class Graph extends React.Component {
|
|||||||
sizeBuffer,
|
sizeBuffer,
|
||||||
svg
|
svg
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
let stateChanges = {};
|
||||||
|
|
||||||
if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") {
|
if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") {
|
||||||
reglRender.cancel();
|
reglRender.cancel();
|
||||||
@@ -148,9 +158,7 @@ class Graph extends React.Component {
|
|||||||
dimension: 2
|
dimension: 2
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState({
|
stateChanges.offset = offset;
|
||||||
offset
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colors for each point - a cached value that only changes when
|
// 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.height !== responsive.height ||
|
||||||
prevProps.responsive.width !== responsive.width ||
|
prevProps.responsive.width !== responsive.width ||
|
||||||
/* first time */
|
/* 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 */
|
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
|
||||||
d3.select("#graphAttachPoint")
|
d3.select("#graphAttachPoint")
|
||||||
.selectAll("svg")
|
.selectAll("svg")
|
||||||
.remove();
|
.remove();
|
||||||
const { svg: newSvg, brush } = setupSVGandBrushElements(
|
|
||||||
this.handleBrushSelectAction.bind(this),
|
let handleStart;
|
||||||
this.handleBrushDeselectAction.bind(this),
|
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,
|
responsive,
|
||||||
this.graphPaddingRight,
|
this.graphPaddingRight
|
||||||
this.handleLassoStart.bind(this),
|
|
||||||
this.handleLassoEnd.bind(this)
|
|
||||||
);
|
);
|
||||||
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());
|
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) {
|
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
|
||||||
regl.clear({
|
regl.clear({
|
||||||
depth: 1,
|
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 { responsive } = this.props;
|
||||||
const { regl, camera, offset } = this.state;
|
const { regl, camera, offset } = this.state;
|
||||||
|
|
||||||
@@ -323,14 +452,45 @@ class Graph extends React.Component {
|
|||||||
x * inverse[14] * aspect + inverse[12],
|
x * inverse[14] * aspect + inverse[12],
|
||||||
y * inverse[14] + inverse[13]
|
y * inverse[14] + inverse[13]
|
||||||
];
|
];
|
||||||
|
|
||||||
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
|
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
|
Map an XY coordinate from cell/point domain to screen range. Inverse
|
||||||
an event on procedural deselect because it is move: null
|
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:
|
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
|
coordinates will be included if selection made, null
|
||||||
but apparently
|
if selection cleared.
|
||||||
it does
|
|
||||||
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
|
|
||||||
*/
|
*/
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
|
const s = d3.event.selection;
|
||||||
if (d3.event.sourceEvent !== null) {
|
if (s) {
|
||||||
const s = d3.event.selection;
|
|
||||||
|
|
||||||
const brushCoords = {
|
const brushCoords = {
|
||||||
northwest: this.invertPoint([s[0][0], s[0][1]]),
|
northwest: this.mapScreenToPoint(s[0]),
|
||||||
southeast: this.invertPoint([s[1][0], s[1][1]])
|
southeast: this.mapScreenToPoint(s[1])
|
||||||
};
|
};
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "graph brush selection change",
|
type: "graph brush end",
|
||||||
brushCoords
|
brushCoords
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
dispatch({
|
||||||
|
type: "graph brush deselect"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBrushDeselectAction() {
|
handleBrushDeselectAction() {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
const { svg, brush } = this.state;
|
dispatch({
|
||||||
|
type: "graph brush deselect"
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLassoStart() {
|
handleLassoStart() {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
// reset selected points when starting a new polygon
|
|
||||||
// making it easier for the user to make the next selection
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "lasso started"
|
type: "graph lasso start"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// when a lasso is completed, filter to the points within the lasso polygon
|
// when a lasso is completed, filter to the points within the lasso polygon
|
||||||
handleLassoEnd(polygon) {
|
handleLassoEnd(polygon) {
|
||||||
const minimumPolygoneArea = 10;
|
const minimumPolygonArea = 10;
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
polygon.length < 3 ||
|
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.
|
// if less than three points, or super small area, treat as a clear selection.
|
||||||
dispatch({ type: "lasso deselect" });
|
dispatch({ type: "graph lasso deselect" });
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "lasso selection",
|
type: "graph lasso end",
|
||||||
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
|
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) {
|
handleOpacityRangeChange(e) {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -425,11 +611,24 @@ class Graph extends React.Component {
|
|||||||
responsive,
|
responsive,
|
||||||
crossfilter,
|
crossfilter,
|
||||||
resettingInterface,
|
resettingInterface,
|
||||||
library_versions,
|
libraryVersions,
|
||||||
undoDisabled,
|
undoDisabled,
|
||||||
redoDisabled
|
redoDisabled,
|
||||||
|
selectionTool
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { mode } = this.state;
|
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 (
|
return (
|
||||||
<div id="graphWrapper">
|
<div id="graphWrapper">
|
||||||
<div
|
<div
|
||||||
@@ -487,16 +686,14 @@ class Graph extends React.Component {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div>
|
<div>
|
||||||
<div className="bp3-button-group">
|
<div className="bp3-button-group">
|
||||||
<Tooltip content="Lasso selection" position="left">
|
<Tooltip content={selectionTooltip} position="left">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="mode-lasso"
|
data-testid="mode-lasso"
|
||||||
className="bp3-button bp3-icon-polygon-filter"
|
className={`bp3-button ${selectionButtonClass}`}
|
||||||
active={mode === "lasso"}
|
active={mode === "select"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
this.handleBrushDeselectAction();
|
this.setState({ mode: "select" });
|
||||||
// this.restartReglLoop();
|
|
||||||
this.setState({ mode: "lasso" });
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer"
|
cursor: "pointer"
|
||||||
@@ -510,7 +707,6 @@ class Graph extends React.Component {
|
|||||||
className="bp3-button bp3-icon-zoom-in"
|
className="bp3-button bp3-icon-zoom-in"
|
||||||
active={mode === "zoom"}
|
active={mode === "zoom"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
this.handleBrushDeselectAction();
|
|
||||||
this.restartReglLoop();
|
this.restartReglLoop();
|
||||||
this.setState({ mode: "zoom" });
|
this.setState({ mode: "zoom" });
|
||||||
}}
|
}}
|
||||||
@@ -578,8 +774,8 @@ class Graph extends React.Component {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
target="_blank"
|
target="_blank"
|
||||||
text={`cellxgene v${
|
text={`cellxgene v${
|
||||||
library_versions && library_versions.cellxgene
|
libraryVersions && libraryVersions.cellxgene
|
||||||
? library_versions.cellxgene
|
? libraryVersions.cellxgene
|
||||||
: null
|
: null
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -609,7 +805,7 @@ class Graph extends React.Component {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: mode === "lasso" ? "inherit" : "none"
|
display: mode === "select" ? "inherit" : "none"
|
||||||
}}
|
}}
|
||||||
id="graphAttachPoint"
|
id="graphAttachPoint"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
|
|
||||||
const Lasso = () => {
|
const Lasso = () => {
|
||||||
const dispatch = d3.dispatch("start", "end");
|
const dispatch = d3.dispatch("start", "end", "cancel");
|
||||||
|
|
||||||
const polygonToPath = polygon =>
|
const polygonToPath = polygon =>
|
||||||
`M${polygon.map(d => d.join(",")).join("L")}`;
|
`M${polygon.map(d => d.join(",")).join("L")}`;
|
||||||
@@ -82,6 +82,7 @@ const Lasso = () => {
|
|||||||
lassoPath.remove();
|
lassoPath.remove();
|
||||||
lassoPath = null;
|
lassoPath = null;
|
||||||
lassoPolygon = null;
|
lassoPolygon = null;
|
||||||
|
dispatch.call("cancel");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +116,23 @@ const Lasso = () => {
|
|||||||
closePath = null;
|
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) => {
|
lasso.on = (type, callback) => {
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import Lasso from "./setupLasso";
|
|||||||
******************************************/
|
******************************************/
|
||||||
|
|
||||||
export default (
|
export default (
|
||||||
handleBrushSelectAction,
|
selectionToolType,
|
||||||
handleBrushDeselectAction,
|
handleStartAction,
|
||||||
|
handleDragAction,
|
||||||
|
handleEndAction,
|
||||||
|
handleCancelAction,
|
||||||
responsive,
|
responsive,
|
||||||
graphPaddingRight,
|
graphPaddingRight
|
||||||
handleLassoStart,
|
|
||||||
handleLassoEnd
|
|
||||||
) => {
|
) => {
|
||||||
const svg = d3
|
const svg = d3
|
||||||
.select("#graphAttachPoint")
|
.select("#graphAttachPoint")
|
||||||
@@ -25,27 +26,37 @@ export default (
|
|||||||
.attr("height", responsive.height)
|
.attr("height", responsive.height)
|
||||||
.attr("class", `${styles.graphSVG}`);
|
.attr("class", `${styles.graphSVG}`);
|
||||||
|
|
||||||
const brush = d3
|
if (selectionToolType === "brush") {
|
||||||
.brush()
|
const brush = d3
|
||||||
.extent([[0, 0], [responsive.width - graphPaddingRight, responsive.height]])
|
.brush()
|
||||||
.on("brush", handleBrushSelectAction)
|
.extent([
|
||||||
.on("end", handleBrushDeselectAction);
|
[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
|
const brushContainer = svg
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("class", "graph_brush")
|
.attr("class", "graph_brush")
|
||||||
.call(brush);
|
.call(brush);
|
||||||
|
|
||||||
const lassoInstance = Lasso()
|
return { svg, container: brushContainer, tool: brush };
|
||||||
.on("end", handleLassoEnd)
|
}
|
||||||
.on("start", handleLassoStart);
|
|
||||||
|
|
||||||
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 {
|
const lassoContainer = svg.call(lasso);
|
||||||
svg,
|
|
||||||
brushContainer,
|
return { svg, container: lassoContainer, tool: lasso };
|
||||||
brush,
|
}
|
||||||
lasso
|
|
||||||
};
|
throw new Error("unknown graph selection tool");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
// }
|
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
makeContinuousDimensionName
|
makeContinuousDimensionName
|
||||||
} from "../util/nameCreators";
|
} from "../util/nameCreators";
|
||||||
|
|
||||||
|
const XYDimName = layoutDimensionName("XY");
|
||||||
|
|
||||||
const CrossfilterReducer = (
|
const CrossfilterReducer = (
|
||||||
state = null,
|
state = null,
|
||||||
action,
|
action,
|
||||||
@@ -109,40 +111,37 @@ const CrossfilterReducer = (
|
|||||||
return crossfilter;
|
return crossfilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "graph brush selection change": {
|
case "graph brush end":
|
||||||
const name = layoutDimensionName("XY");
|
case "graph brush change": {
|
||||||
const [x0, y0] = action.brushCoords.northwest;
|
const [minX, maxY] = action.brushCoords.northwest;
|
||||||
const [x1, y1] = action.brushCoords.southeast;
|
const [maxX, minY] = action.brushCoords.southeast;
|
||||||
return state.select(name, {
|
return state.select(XYDimName, {
|
||||||
mode: "within-rect",
|
mode: "within-rect",
|
||||||
x0,
|
minX,
|
||||||
y0,
|
minY,
|
||||||
x1,
|
maxX,
|
||||||
y1
|
maxY
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case "lasso deselect":
|
case "graph lasso end": {
|
||||||
case "graph brush deselect": {
|
|
||||||
const name = layoutDimensionName("XY");
|
|
||||||
return state.select(name, { mode: "all" });
|
|
||||||
}
|
|
||||||
|
|
||||||
case "lasso selection": {
|
|
||||||
const { polygon } = action;
|
const { polygon } = action;
|
||||||
const name = layoutDimensionName("XY");
|
return state.select(XYDimName, {
|
||||||
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",
|
mode: "within-polygon",
|
||||||
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 start":
|
||||||
case "continuous metadata histogram brush":
|
case "continuous metadata histogram brush":
|
||||||
|
case "continuous metadata histogram cancel":
|
||||||
case "continuous metadata histogram end": {
|
case "continuous metadata histogram end": {
|
||||||
const name = makeContinuousDimensionName(
|
const name = makeContinuousDimensionName(
|
||||||
action.continuousNamespace,
|
action.continuousNamespace,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -8,6 +8,7 @@ import universe from "./universe";
|
|||||||
import world from "./world";
|
import world from "./world";
|
||||||
import categoricalSelection from "./categoricalSelection";
|
import categoricalSelection from "./categoricalSelection";
|
||||||
import continuousSelection from "./continuousSelection";
|
import continuousSelection from "./continuousSelection";
|
||||||
|
import graphSelection from "./graphSelection";
|
||||||
import crossfilter from "./crossfilter";
|
import crossfilter from "./crossfilter";
|
||||||
import colors from "./colors";
|
import colors from "./colors";
|
||||||
import differential from "./differential";
|
import differential from "./differential";
|
||||||
@@ -15,48 +16,7 @@ import responsive from "./responsive";
|
|||||||
import controls from "./controls";
|
import controls from "./controls";
|
||||||
import resetCache from "./resetCache";
|
import resetCache from "./resetCache";
|
||||||
|
|
||||||
const ignoredActions = new Set([
|
import undoableConfig from "./undoableConfig";
|
||||||
// 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)
|
|
||||||
};
|
|
||||||
|
|
||||||
const Reducer = undoable(
|
const Reducer = undoable(
|
||||||
cascadeReducers([
|
cascadeReducers([
|
||||||
@@ -65,6 +25,7 @@ const Reducer = undoable(
|
|||||||
["world", world],
|
["world", world],
|
||||||
["categoricalSelection", categoricalSelection],
|
["categoricalSelection", categoricalSelection],
|
||||||
["continuousSelection", continuousSelection],
|
["continuousSelection", continuousSelection],
|
||||||
|
["graphSelection", graphSelection],
|
||||||
["crossfilter", crossfilter],
|
["crossfilter", crossfilter],
|
||||||
["colors", colors],
|
["colors", colors],
|
||||||
["controls", controls],
|
["controls", controls],
|
||||||
@@ -76,6 +37,7 @@ const Reducer = undoable(
|
|||||||
"world",
|
"world",
|
||||||
"categoricalSelection",
|
"categoricalSelection",
|
||||||
"continuousSelection",
|
"continuousSelection",
|
||||||
|
"graphSelection",
|
||||||
"crossfilter",
|
"crossfilter",
|
||||||
"colors",
|
"colors",
|
||||||
"controls",
|
"controls",
|
||||||
|
|||||||
+155
-22
@@ -8,35 +8,73 @@ Requires three parameters:
|
|||||||
state to be made "undoable".
|
state to be made "undoable".
|
||||||
* options - an optional object, which may contain the following parameters:
|
* options - an optional object, which may contain the following parameters:
|
||||||
* historyLimit: max number of historical states to remember (aka max undo depth)
|
* historyLimit: max number of historical states to remember (aka max undo depth)
|
||||||
* skipActionFilter: filter function, (state, action) => bool. If it returns
|
* actionFilter: filter function, (state, action, filterState) => value.
|
||||||
truthy, the current state will not be pushed onto the history stack.
|
See below for details.
|
||||||
* clearOnActionFilter: filter function, (state, action) => bool. If it returns
|
* debug: if truish, will print helpful log messages about history manipulation
|
||||||
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:
|
This meta reducer accepts three actions types:
|
||||||
* @@undoable/undo - move back in history
|
* @@undoable/undo - move back in history
|
||||||
* @@undoable/redo - move forward in history
|
* @@undoable/redo - move forward in history
|
||||||
* @@undoable/clear - clear 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 historyKeyPrefix = "@@undoable/";
|
||||||
const pastKey = `${historyKeyPrefix}past`;
|
const pastKey = `${historyKeyPrefix}past`;
|
||||||
const futureKey = `${historyKeyPrefix}future`;
|
const futureKey = `${historyKeyPrefix}future`;
|
||||||
|
const filterStateKey = `${historyKeyPrefix}filterState`;
|
||||||
|
const filterActionKey = `${historyKeyPrefix}filterAction`;
|
||||||
|
const pendingKey = `${historyKeyPrefix}pending`;
|
||||||
const defaultHistoryLimit = -100;
|
const defaultHistoryLimit = -100;
|
||||||
|
|
||||||
const Undoable = (reducer, undoableKeys, options = {}) => {
|
const Undoable = (reducer, undoableKeys, options = {}) => {
|
||||||
|
const { debug } = options;
|
||||||
let { historyLimit } = options;
|
let { historyLimit } = options;
|
||||||
if (!historyLimit) historyLimit = defaultHistoryLimit;
|
if (!historyLimit) historyLimit = defaultHistoryLimit;
|
||||||
if (historyLimit > 0) historyLimit = -historyLimit;
|
if (historyLimit > 0) historyLimit = -historyLimit;
|
||||||
const skipActionFilter = options.skipActionFilter || (() => false);
|
const actionFilter =
|
||||||
const clearOnActionFilter = options.clearOnActionFilter || (() => false);
|
options.actionFilter || (() => ({ [filterActionKey]: "save" }));
|
||||||
|
|
||||||
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
|
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
|
||||||
throw new Error("undoable keys array must be specified");
|
throw new Error("undoable keys array must be specified");
|
||||||
const undoableKeysSet = new Set(undoableKeys);
|
const undoableKeysSet = new Set(undoableKeys);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Undo the current to previous history
|
||||||
|
*/
|
||||||
function undo(currentState) {
|
function undo(currentState) {
|
||||||
const past = currentState[pastKey];
|
const past = currentState[pastKey];
|
||||||
const future = currentState[futureKey];
|
const future = currentState[futureKey];
|
||||||
@@ -51,11 +89,15 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
|
|||||||
...currentState,
|
...currentState,
|
||||||
...fromEntries(newState),
|
...fromEntries(newState),
|
||||||
[pastKey]: newPast,
|
[pastKey]: newPast,
|
||||||
[futureKey]: newFuture
|
[futureKey]: newFuture,
|
||||||
|
[pendingKey]: null
|
||||||
};
|
};
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Replay future, previously undone.
|
||||||
|
*/
|
||||||
function redo(currentState) {
|
function redo(currentState) {
|
||||||
const past = currentState[pastKey] || [];
|
const past = currentState[pastKey] || [];
|
||||||
const future = currentState[futureKey] || [];
|
const future = currentState[futureKey] || [];
|
||||||
@@ -70,30 +112,45 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
|
|||||||
...currentState,
|
...currentState,
|
||||||
...fromEntries(newState),
|
...fromEntries(newState),
|
||||||
[pastKey]: newPast,
|
[pastKey]: newPast,
|
||||||
[futureKey]: newFuture
|
[futureKey]: newFuture,
|
||||||
|
[pendingKey]: null
|
||||||
};
|
};
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Clear the history state. No side-effects on current state.
|
||||||
|
*/
|
||||||
function clear(currentState) {
|
function clear(currentState) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
[pastKey]: [],
|
[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 past = currentState[pastKey] || [];
|
||||||
|
const pending = currentState[pendingKey];
|
||||||
const res = reducer(currentState, action);
|
const res = reducer(currentState, action);
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
[pastKey]: past,
|
[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 past = currentState[pastKey] || [];
|
||||||
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||||
undoableKeysSet.has(kv[0])
|
undoableKeysSet.has(kv[0])
|
||||||
@@ -103,7 +160,48 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
|
|||||||
const nextState = {
|
const nextState = {
|
||||||
...res,
|
...res,
|
||||||
[pastKey]: newPast,
|
[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;
|
return nextState;
|
||||||
}
|
}
|
||||||
@@ -111,7 +209,9 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
|
|||||||
return (
|
return (
|
||||||
currentState = {
|
currentState = {
|
||||||
[pastKey]: [],
|
[pastKey]: [],
|
||||||
[futureKey]: []
|
[futureKey]: [],
|
||||||
|
[filterStateKey]: {},
|
||||||
|
[pendingKey]: null
|
||||||
},
|
},
|
||||||
action
|
action
|
||||||
) => {
|
) => {
|
||||||
@@ -120,20 +220,53 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
|
|||||||
case "@@undoable/undo": {
|
case "@@undoable/undo": {
|
||||||
return undo(currentState, action);
|
return undo(currentState, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "@@undoable/redo": {
|
case "@@undoable/redo": {
|
||||||
return redo(currentState, action);
|
return redo(currentState, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "@@undoable/clear": {
|
case "@@undoable/clear": {
|
||||||
return clear(currentState, action);
|
return clear(currentState, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
if (skipActionFilter(currentState, action)) {
|
const currentFilterState = currentState[filterStateKey];
|
||||||
return skip(currentState, action);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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) {
|
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 { X, Y } = this;
|
||||||
const ranges = [];
|
const ranges = [];
|
||||||
let start = -1;
|
let start = -1;
|
||||||
for (let i = 0, l = X.length; i < l; i += 1) {
|
for (let i = 0, l = X.length; i < l; i += 1) {
|
||||||
const x = X[i];
|
const x = X[i];
|
||||||
const y = Y[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) start = i;
|
||||||
if (!inside && start !== -1) {
|
if (!inside && start !== -1) {
|
||||||
ranges.push([start, i]);
|
ranges.push([start, i]);
|
||||||
|
|||||||
Reference in New Issue
Block a user