diff --git a/client/__tests__/reducers/cascade.test.js b/client/__tests__/reducers/cascade.test.js new file mode 100644 index 00000000..5395025b --- /dev/null +++ b/client/__tests__/reducers/cascade.test.js @@ -0,0 +1,48 @@ +import cascadeReducers from "../../src/reducers/cascade"; + +describe("create", () => { + test("from Array", () => { + expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function); + }); + + test("from Map", () => { + expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf( + Function + ); + }); +}); + +describe("cascade", () => { + test("expected arguments provided & cascade ordering", () => { + const topLevelState = {}; + const topLevelAction = { type: "test" }; + + const reducer = cascadeReducers([ + [ + "foo", + (currentState, action, nextSharedState, prevSharedState) => { + expect(currentState).toBeUndefined(); + expect(action).toEqual(topLevelAction); + expect(nextSharedState).toStrictEqual({}); + expect(prevSharedState).toBe(topLevelState); + return 0; + } + ], + [ + "bar", + (currentState, action, nextSharedState, prevSharedState) => { + expect(currentState).toBeUndefined(); + expect(action).toEqual(topLevelAction); + expect(nextSharedState).toStrictEqual({ foo: 0 }); + expect(prevSharedState).toBe(topLevelState); + return 99; + } + ] + ]); + + const nextState = reducer(topLevelState, topLevelAction); + expect(nextState).toStrictEqual({ foo: 0, bar: 99 }); + expect(topLevelState).toStrictEqual({}); + expect(topLevelAction).toStrictEqual({ type: "test" }); + }); +}); diff --git a/client/__tests__/reducers/undoable.test.js b/client/__tests__/reducers/undoable.test.js new file mode 100644 index 00000000..40754e1e --- /dev/null +++ b/client/__tests__/reducers/undoable.test.js @@ -0,0 +1,87 @@ +import undoable from "../../src/reducers/undoable"; + +describe("create", () => { + test("no keys", () => { + expect(() => undoable(() => {})).toThrow(); + expect(() => undoable(() => {}, null)).toThrow(); + expect(() => undoable(() => {}, [])).toThrow(); + expect(() => undoable(() => {}, [], {})).toThrow(); + }); + + test("simple", () => { + expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function); + expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function); + }); + + test("handles undefined initial state", () => { + expect( + undoable(() => {}, ["a"])(undefined, { type: "test" }) + ).toMatchObject({}); + }); +}); + +describe("undo", () => { + test("expected state modifications", () => { + const initialState = { a: 0, b: 1000 }; + const reducer = state => { + return { a: state.a + 1, b: state.b + 1 }; + }; + const undoableReducer = undoable(reducer, ["a"]); + + const s1 = undoableReducer(initialState, { type: "test" }); + expect(s1).toMatchObject({ a: 1, b: 1001 }); + + // test that only specified keys are undone + const s2 = undoableReducer(s1, { type: "@@undoable/undo" }); + expect(s2).toMatchObject({ a: 0, b: 1001 }); + + // test backstop when no more history + const s3 = undoableReducer(s2, { type: "@@undoable/undo" }); + expect(s3).toMatchObject({ a: 0, b: 1001 }); + }); +}); + +describe("redo", () => { + const initialState = { a: 0, b: 1000 }; + const reducer = state => { + return { a: state.a + 1, b: state.b + 1 }; + }; + let UR; + + beforeEach(() => { + UR = undoable(reducer, ["a"]); + }); + + test("expected state modifications", () => { + const s1 = UR(initialState, { type: "test" }); + expect(s1).toMatchObject({ a: 1, b: 1001 }); + + // verify undo->redo reverts state. + const s2 = UR(UR(s1, { type: "@@undoable/undo" }), { + type: "@@undoable/redo" + }); + expect(s2).toMatchObject({ a: 1, b: 1001 }); + + // verify backstop when no redo future + const s3 = UR(s2, { type: "@@undoable/redo" }); + expect(s3).toMatchObject({ a: 1, b: 1001 }); + }); + + test("history cleared", () => { + // verify future cleared upon a normal state transition + const s1 = UR(initialState, { type: "test" }); + expect(s1).toMatchObject({ a: 1, b: 1001 }); + const s2 = UR(s1, { type: "@@undoable/undo" }); + expect(s2).toMatchObject({ a: 0, b: 1001 }); + const s3 = UR(s2, { type: "test" }); + expect(s3).toMatchObject({ a: 1, b: 1002 }); + const s4 = UR(s3, { type: "@@undoable/redo" }); + expect(s4).toMatchObject({ a: 1, b: 1002 }); + }); +}); + +/* +TODO: +- historyLimit is enforced +- action filters +*/ diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 7a62f3b4..0865cd57 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -65,7 +65,7 @@ Set the view (world) to current selection. Placeholder for an async action which also does re-layout. */ const regraph = () => (dispatch, getState) => { - const { universe, world, crossfilter } = getState().controls; + const { universe, world, crossfilter } = getState(); dispatch({ type: "set World to current selection", universe, @@ -124,7 +124,7 @@ async function _doRequestExpressionData(dispatch, getState, genes) { }; const state = getState(); - const { universe } = state.controls; + const { universe } = state; /* preload data already in cache */ let expressionData = _.transform( genes, @@ -164,7 +164,7 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) { dispatch({ type: "get single gene expression for coloring started" }); try { await _doRequestExpressionData(dispatch, getState, [gene]); - const { world } = getState().controls; + const { world } = getState(); dispatch({ type: "color by expression", gene, @@ -185,7 +185,7 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => { dispatch({ type: "request user defined gene started" }); try { await await _doRequestExpressionData(dispatch, getState, [gene]); - const { world } = getState().controls; + const { world } = getState(); /* then send the success case action through */ return dispatch({ @@ -240,7 +240,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async ( 2. get expression data for each */ const state = getState(); - const { universe } = state.controls; + const { universe } = state; // Legal values are null, Array or TypedArray. Null is initial state. if (!set1) set1 = []; @@ -300,7 +300,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async ( }; const resetInterface = () => (dispatch, getState) => { - const { universe } = getState().controls; + const { universe } = getState(); dispatch({ type: "clear all user defined genes" diff --git a/client/src/components/brushableHistogram/index.js b/client/src/components/brushableHistogram/index.js index 9c3a6fcf..b8b2f1a6 100644 --- a/client/src/components/brushableHistogram/index.js +++ b/client/src/components/brushableHistogram/index.js @@ -15,13 +15,13 @@ import actions from "../../actions"; import finiteExtent from "../../util/finiteExtent"; @connect(state => ({ - world: state.controls.world, + world: state.world, scatterplotXXaccessor: state.controls.scatterplotXXaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor, - crossfilter: state.controls.crossfilter, + crossfilter: state.crossfilter, differential: state.differential, - colorAccessor: state.controls.colorAccessor, - obsAnnotations: _.get(state.controls.world, "obsAnnotations", null) + colorAccessor: state.colors.colorAccessor, + obsAnnotations: _.get(state.world, "obsAnnotations", null) })) class HistogramBrush extends React.Component { calcHistogramCache = memoize((obsAnnotations, field, rangeMin, rangeMax) => { @@ -94,13 +94,14 @@ class HistogramBrush extends React.Component { } } - onBrush(selection, x) { + onBrush(selection, x, eventType) { + const type = `continuous metadata histogram ${eventType}`; return () => { const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props; if (d3.event.selection) { dispatch({ - type: "continuous metadata histogram brush", + type, selection: field, continuousNamespace: { isObs, @@ -111,7 +112,7 @@ class HistogramBrush extends React.Component { }); } else { dispatch({ - type: "continuous metadata histogram brush", + type, selection: field, continuousNamespace: { isObs, @@ -157,7 +158,7 @@ class HistogramBrush extends React.Component { } dispatch({ - type: "continuous metadata histogram brush", + type: "continuous metadata histogram end", selection: field, continuousNamespace: { isObs, @@ -168,7 +169,7 @@ class HistogramBrush extends React.Component { }); } else { dispatch({ - type: "continuous metadata histogram brush", + type: "continuous metadata histogram end", selection: field, continuousNamespace: { isObs, @@ -289,7 +290,12 @@ class HistogramBrush extends React.Component { .call( d3 .brushX() - .on("brush", this.onBrush(field, x.invert).bind(this)) + /* + emit start so that the Undoable history can save an undo point + upon drag start, and ignore the subsequent intermediate drag events. + */ + .on("start", this.onBrush(field, x.invert, "start").bind(this)) + .on("brush", this.onBrush(field, x.invert, "brush").bind(this)) .on("end", this.onBrushEnd(field, x.invert).bind(this)) ); diff --git a/client/src/components/categorical/categorical.js b/client/src/components/categorical/categorical.js index 25d4646c..4c94c17b 100644 --- a/client/src/components/categorical/categorical.js +++ b/client/src/components/categorical/categorical.js @@ -6,12 +6,12 @@ import * as globals from "../../globals"; import Category from "./category"; @connect(state => ({ - categoricalSelectionState: state.controls.categoricalSelectionState + categoricalSelection: state.categoricalSelection })) class Categories extends React.Component { render() { - const { categoricalSelectionState } = this.props; - if (!categoricalSelectionState) return null; + const { categoricalSelection } = this.props; + if (!categoricalSelection) return null; return (
Categorical Metadata

- {_.map(categoricalSelectionState, (catState, catName) => ( + {_.map(categoricalSelection, (catState, catName) => ( ))}
diff --git a/client/src/components/categorical/category.js b/client/src/components/categorical/category.js index 1cbb73d1..b501928d 100644 --- a/client/src/components/categorical/category.js +++ b/client/src/components/categorical/category.js @@ -9,8 +9,8 @@ import Value from "./value"; import sortedCategoryValues from "./util"; @connect(state => ({ - colorAccessor: state.controls.colorAccessor, - categoricalSelectionState: state.controls.categoricalSelectionState + colorAccessor: state.colors.colorAccessor, + categoricalSelection: state.categoricalSelection })) class Category extends React.Component { constructor(props) { @@ -22,9 +22,9 @@ class Category extends React.Component { } componentDidUpdate(prevProps) { - const { categoricalSelectionState, metadataField } = this.props; - if (categoricalSelectionState !== prevProps.categoricalSelectionState) { - const cat = categoricalSelectionState[metadataField]; + const { categoricalSelection, metadataField } = this.props; + if (categoricalSelection !== prevProps.categoricalSelection) { + const cat = categoricalSelection[metadataField]; const categoryCount = { // total number of categories in this dimension totalCatCount: cat.numCategories, @@ -87,9 +87,9 @@ class Category extends React.Component { } renderCategoryItems() { - const { categoricalSelectionState, metadataField } = this.props; + const { categoricalSelection, metadataField } = this.props; - const cat = categoricalSelectionState[metadataField]; + const cat = categoricalSelection[metadataField]; const optTuples = sortedCategoryValues([...cat.categoryIndices]); return _.map(optTuples, (tuple, i) => ( { - const o = occupancy.get(d); + const stacks = categoricalSelection[colorAccessor].categoryValues.map(d => { + const o = occupancy.get(d); - const scaledValue = x(o); + const scaledValue = x(o); - const stackItem = { - key: d, - value: o || 0, - rectWidth: o ? scaledValue : 0, - offset: currentOffset, - fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)" - }; - currentOffset += o ? scaledValue : 0; - return stackItem; - } - ); + const stackItem = { + key: d, + value: o || 0, + rectWidth: o ? scaledValue : 0, + offset: currentOffset, + fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)" + }; + currentOffset += o ? scaledValue : 0; + return stackItem; + }); return ( ({ - categoricalSelectionState: state.controls.categoricalSelectionState, - colorScale: state.controls.colors.scale, - colorAccessor: state.controls.colorAccessor, - schema: _.get(state.controls.world, "schema", null), - world: state.controls.world + categoricalSelection: state.categoricalSelection, + colorScale: state.colors.scale, + colorAccessor: state.colors.colorAccessor, + schema: _.get(state.world, "schema", null), + world: state.world })) class CategoryValue extends React.Component { toggleOff() { @@ -34,7 +34,7 @@ class CategoryValue extends React.Component { render() { const { - categoricalSelectionState, + categoricalSelection, metadataField, categoryIndex, colorAccessor, @@ -44,9 +44,9 @@ class CategoryValue extends React.Component { world } = this.props; - if (!categoricalSelectionState) return null; + if (!categoricalSelection) return null; - const category = categoricalSelectionState[metadataField]; + const category = categoricalSelection[metadataField]; const selected = category.categorySelected[categoryIndex]; const count = category.categoryCounts[categoryIndex]; const value = category.categoryValues[categoryIndex]; @@ -65,11 +65,7 @@ class CategoryValue extends React.Component { })[0].categories; } - if ( - colorAccessor && - !isColorBy && - categoricalSelectionState[colorAccessor] - ) { + if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) { occupancy = countCategoryValues2D( metadataField, colorAccessor, @@ -112,7 +108,7 @@ class CategoryValue extends React.Component { {colorAccessor && !isColorBy && - categoricalSelectionState[colorAccessor] ? ( + categoricalSelection[colorAccessor] ? ( ({ - obsAnnotations: _.get(state.controls.world, "obsAnnotations", null), - colorAccessor: state.controls.colorAccessor, - colorScale: state.controls.colorScale, - schema: _.get(state.controls.world, "schema", null) + obsAnnotations: _.get(state.world, "obsAnnotations", null), + colorAccessor: state.colors.colorAccessor, + colorScale: state.colors.scale, + schema: _.get(state.world, "schema", null) })) class Continuous extends React.Component { constructor(props) { diff --git a/client/src/components/continuousLegend/index.js b/client/src/components/continuousLegend/index.js index 415df0a3..a68c086f 100644 --- a/client/src/components/continuousLegend/index.js +++ b/client/src/components/continuousLegend/index.js @@ -98,8 +98,8 @@ const continuous = (selectorId, colorscale, colorAccessor) => { }; @connect(state => ({ - colorAccessor: state.controls.colorAccessor, - colorScale: state.controls.colors.scale, + colorAccessor: state.colors.colorAccessor, + colorScale: state.colors.scale, responsive: state.responsive })) class ContinuousLegend extends React.Component { diff --git a/client/src/components/geneExpression/expressionButtons.js b/client/src/components/geneExpression/expressionButtons.js index fe47c02b..9185a365 100644 --- a/client/src/components/geneExpression/expressionButtons.js +++ b/client/src/components/geneExpression/expressionButtons.js @@ -9,8 +9,8 @@ import CellSetButton from "./cellSetButtons"; @connect(state => ({ differential: state.differential, - world: state.controls.world, - crossfilter: state.controls.crossfilter + world: state.world, + crossfilter: state.crossfilter })) class Expression extends React.Component { constructor(props) { diff --git a/client/src/components/geneExpression/index.js b/client/src/components/geneExpression/index.js index 8da10e65..c6620a5e 100644 --- a/client/src/components/geneExpression/index.js +++ b/client/src/components/geneExpression/index.js @@ -58,11 +58,11 @@ const filterGenes = (query, genes) => @connect(state => { return { - obsAnnotations: _.get(state.controls.world, "obsAnnotations", null), + obsAnnotations: _.get(state.world, "obsAnnotations", null), userDefinedGenes: state.controls.userDefinedGenes, userDefinedGenesLoading: state.controls.userDefinedGenesLoading, - world: state.controls.world, - colorAccessor: state.controls.colorAccessor, + world: state.world, + colorAccessor: state.colors.colorAccessor, differential: state.differential }; }) @@ -118,11 +118,9 @@ class GeneExpression extends React.Component { } else if (world.varAnnotations.col("name").indexOf(gene) === undefined) { postUserErrorToast("That doesn't appear to be a valid gene name."); } else { + dispatch({ type: "single user defined gene start" }); dispatch(actions.requestUserDefinedGene(gene)); - dispatch({ - type: "user defined gene", - data: gene - }); + dispatch({ type: "single user defined gene complete" }); } } @@ -137,6 +135,7 @@ class GeneExpression extends React.Component { if (bulkAdd !== "") { const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), ""); + dispatch({ type: "bulk user defined gene start" }); genes.forEach(gene => { if (gene.length === 0) { keepAroundErrorToast("Must enter a gene name."); @@ -150,12 +149,9 @@ class GeneExpression extends React.Component { ); } else { dispatch(actions.requestUserDefinedGene(gene)); - dispatch({ - type: "user defined gene", - data: gene - }); } }); + dispatch({ type: "bulk user defined gene complete" }); } this.setState({ bulkAdd: "" }); diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index 2884dff8..20649564 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -26,21 +26,23 @@ import { World } from "../../util/stateManager"; /* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */ @connect(state => ({ - world: state.controls.world, - universe: state.controls.universe, - crossfilter: state.controls.crossfilter, + world: state.world, + universe: state.universe, + crossfilter: state.crossfilter, responsive: state.responsive, - colorRGB: _.get(state.controls, "colors.rgb", null), + colorRGB: state.colors.rgb, opacityForDeselectedCells: state.controls.opacityForDeselectedCells, resettingInterface: state.controls.resettingInterface, userDefinedGenes: state.controls.userDefinedGenes, diffexpGenes: state.controls.diffexpGenes, - colorAccessor: state.controls.colorAccessor, + colorAccessor: state.colors.colorAccessor, scatterplotXXaccessor: state.controls.scatterplotXXaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor, celllist1: state.differential.celllist1, celllist2: state.differential.celllist2, - library_versions: _.get(state.config, "library_versions", null) + library_versions: _.get(state.config, "library_versions", null), + undoDisabled: state["@@undoable/past"].length === 0, + redoDisabled: state["@@undoable/future"].length === 0 })) class Graph extends React.Component { constructor(props) { @@ -392,12 +394,18 @@ class Graph extends React.Component { // when a lasso is completed, filter to the points within the lasso polygon handleLassoEnd(polygon) { + const minimumPolygoneArea = 10; const { dispatch } = this.props; - dispatch({ - type: "lasso selection", - polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon - }); + if (polygon.length < 3 || d3.polygonArea(polygon) < minimumPolygoneArea) { + // if less than three points, or super small area, treat as a clear selection. + dispatch({ type: "lasso deselect" }); + } else { + dispatch({ + type: "lasso selection", + polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon + }); + } } handleOpacityRangeChange(e) { @@ -414,7 +422,9 @@ class Graph extends React.Component { responsive, crossfilter, resettingInterface, - library_versions + library_versions, + undoDisabled, + redoDisabled } = this.props; const { mode } = this.state; return ( @@ -502,6 +512,32 @@ class Graph extends React.Component { }} /> + + { + dispatch({ type: "@@undoable/undo" }); + }} + style={{ + cursor: "pointer" + }} + /> + + + { + dispatch({ type: "@@undoable/redo" }); + }} + style={{ + cursor: "pointer" + }} + /> +
diff --git a/client/src/components/scatterplot/scatterplot.js b/client/src/components/scatterplot/scatterplot.js index b4691161..53fc1d5a 100644 --- a/client/src/components/scatterplot/scatterplot.js +++ b/client/src/components/scatterplot/scatterplot.js @@ -22,12 +22,8 @@ import { margin, width, height } from "./util"; import finiteExtent from "../../util/finiteExtent"; @connect(state => { - const { - world, - crossfilter, - scatterplotXXaccessor, - scatterplotYYaccessor - } = state.controls; + const { world, crossfilter } = state; + const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls; const expressionX = world && scatterplotXXaccessor && @@ -44,9 +40,9 @@ import finiteExtent from "../../util/finiteExtent"; return { world, - colorRGB: _.get(state.controls, "colors.rgb", null), - colorScale: _.get(state.controls, "colors.scale", null), - colorAccessor: state.controls.colorAccessor, + colorRGB: state.colors.rgb, + colorScale: state.colors.scale, + colorAccessor: state.colors.colorAccessor, // Accessors are var/gene names (strings) scatterplotXXaccessor, diff --git a/client/src/reducers/cascade.js b/client/src/reducers/cascade.js new file mode 100644 index 00000000..3ae980ec --- /dev/null +++ b/client/src/reducers/cascade.js @@ -0,0 +1,44 @@ +export default function cascadeReducers(arg) { + /* + Combined a set of cascading reducers into a single reducer. Cascading + reducers are reducers which may rely on state computed by another reducer. + Therefore, they: + - must be composed in a particular order (currently, this is a simple + linear list of reducers, run in list order) + - must have access to partially updated "next state" so they can further + derive state. + + Parameter is one of: + - a Map object + - an array of tuples, [ [key1, reducer1], [key2, reducer2], ... ] + Ie, cascadeReducers([ ["a", reduceA], ["b", reduceB] ]) + + Each reducer will be called with the sigature: + (prevState, action, sharedNextState, sharedPrevState) => newState + + cascadeReducers will build a composite newState object, much + like combinedReducers. Additional semantics: + - reducers guaranteed to be called in order + - each reducer will receive shared objects + */ + const reducers = arg instanceof Map ? arg : new Map(arg); + const reducerKeys = [...reducers.keys()]; + return (prevState, action) => { + const nextState = {}; + let stateChange = false; + for (let i = 0, l = reducerKeys.length; i < l; i += 1) { + const key = reducerKeys[i]; + const reducer = reducers.get(key); + const prevStateForKey = prevState ? prevState[key] : undefined; + const nextStateForKey = reducer( + prevStateForKey, + action, + nextState, + prevState + ); + nextState[key] = nextStateForKey; + stateChange = stateChange || nextStateForKey !== prevStateForKey; + } + return stateChange ? nextState : prevState; + }; +} diff --git a/client/src/reducers/categoricalSelection.js b/client/src/reducers/categoricalSelection.js new file mode 100644 index 00000000..400d6547 --- /dev/null +++ b/client/src/reducers/categoricalSelection.js @@ -0,0 +1,105 @@ +import _ from "lodash"; + +import { ControlsHelpers } from "../util/stateManager"; +import * as globals from "../globals"; + +function maxCategoryItems(state) { + return _.get( + state.config, + "parameters.max-category-items", + globals.configDefaults.parameters["max-category-items"] + ); +} + +const CategoricalSelection = ( + state, + action, + nextSharedState, + prevSharedState +) => { + switch (action.type) { + case "initial data load complete (universe exists)": + case "set World to current selection": + case "reset World to eq Universe": { + const { world } = nextSharedState; + return ControlsHelpers.createCategoricalSelection( + maxCategoryItems(prevSharedState), + world + ); + } + + case "categorical metadata filter select": { + /* + Set the specific category in this field to false + */ + const newCategorySelected = Array.from( + state[action.metadataField].categorySelected + ); + newCategorySelected[action.categoryIndex] = true; + const newCategoricalSelection = { + ...state, + [action.metadataField]: { + ...state[action.metadataField], + categorySelected: newCategorySelected + } + }; + return newCategoricalSelection; + } + + case "categorical metadata filter deselect": { + /* + Set the specific category in this field to false + */ + const newCategorySelected = Array.from( + state[action.metadataField].categorySelected + ); + newCategorySelected[action.categoryIndex] = false; + const newCategoricalSelection = { + ...state, + [action.metadataField]: { + ...state[action.metadataField], + categorySelected: newCategorySelected + } + }; + return newCategoricalSelection; + } + + case "categorical metadata filter none of these": { + /* + set all categories in this field to false. + */ + const newCategoricalSelection = { + ...state, + [action.metadataField]: { + ...state[action.metadataField], + categorySelected: Array.from( + state[action.metadataField].categorySelected + ).fill(false) + } + }; + return newCategoricalSelection; + } + + case "categorical metadata filter all of these": { + /* + set all categories in this field to true. + */ + const newCategoricalSelection = { + ...state, + [action.metadataField]: { + ...state[action.metadataField], + categorySelected: Array.from( + state[action.metadataField].categorySelected + ).fill(true) + } + }; + return newCategoricalSelection; + } + + default: { + return state; + } + } +}; + +export default CategoricalSelection; diff --git a/client/src/reducers/colors.js b/client/src/reducers/colors.js new file mode 100644 index 00000000..11fcf5ed --- /dev/null +++ b/client/src/reducers/colors.js @@ -0,0 +1,88 @@ +import { createColors } from "../util/stateManager"; + +const ColorsReducer = ( + state = { + colorMode: null, + colorAccessor: null, + rgb: null, + scale: null + }, + action, + nextSharedState, + prevSharedState +) => { + switch (action.type) { + case "initial data load complete (universe exists)": + case "reset World to eq Universe": { + const { world } = nextSharedState; + const colorMode = null; + const colorAccessor = null; + const { rgb, scale } = createColors(world, colorMode); + return { + ...state, + colorAccessor, + colorMode, + rgb, + scale + }; + } + + case "set World to current selection": { + const { colorMode, colorAccessor } = state; + const { world } = nextSharedState; + const { rgb, scale } = createColors(world, colorMode, colorAccessor); + return { + ...state, + rgb, + scale + }; + } + + case "reset colorscale": { + const { world } = prevSharedState; + const { rgb, scale } = createColors(world); + return { + ...state, + colorMode: null, + colorAccessor: null, + rgb, + scale + }; + } + + case "color by categorical metadata": + case "color by continuous metadata": { + const { world } = prevSharedState; + const { rgb, scale } = createColors( + world, + action.type, + action.colorAccessor + ); + return { + ...state, + colorMode: action.type, + colorAccessor: action.colorAccessor, + rgb, + scale + }; + } + + case "color by expression": { + const { world } = prevSharedState; + const { rgb, scale } = createColors(world, action.type, action.gene); + return { + ...state, + colorMode: action.type, + colorAccessor: action.gene, + rgb, + scale + }; + } + + default: { + return state; + } + } +}; + +export default ColorsReducer; diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js index 6a43235e..89096749 100644 --- a/client/src/reducers/controls.js +++ b/client/src/reducers/controls.js @@ -2,21 +2,7 @@ import _ from "lodash"; -import { - World, - WorldUtil, - ControlsHelpers, - createColors -} from "../util/stateManager"; -import Crossfilter from "../util/typedCrossfilter"; -import * as globals from "../globals"; -import { - layoutDimensionName, - obsAnnoDimensionName, - userDefinedDimensionName, - diffexpDimensionName, - makeContinuousDimensionName -} from "../util/nameCreators"; +import { WorldUtil } from "../util/stateManager"; const Controls = ( state = { @@ -24,39 +10,23 @@ const Controls = ( loading: false, error: null, - // configuration - maxCategoryItems: globals.configDefaults.parameters["max-category-items"], - - // the whole big bang - universe: null, - fullUniverseCache: null, - // all of the data + selection state - world: null, - categoricalSelectionState: null, - crossfilter: null, userDefinedGenes: [], userDefinedGenesLoading: false, diffexpGenes: [], - // graph color-by - colorMode: null, - colorAccessor: null, - colors: {}, - resettingInterface: false, opacityForDeselectedCells: 0.2, - graphBrushSelection: null, - continuousSelection: null, scatterplotXXaccessor: null, // just easier to read scatterplotYYaccessor: null, - axesHaveBeenDrawn: false, graphRenderCounter: 0 /* integer as { /* For now, log anything looking like an error to the console. @@ -70,190 +40,32 @@ const Controls = ( Initialization, World/Universe management and data loading. ******************************************************/ - case "configuration load complete": { - // there are a couple of configuration items we need to retain - return { - ...state, - maxCategoryItems: _.get( - state.config, - "parameters.max-category-items", - globals.configDefaults.parameters["max-category-items"] - ) - }; - } case "initial data load start": { return { ...state, loading: true }; } case "initial data load complete (universe exists)": { /* first light - create world & other data-driven defaults */ - const { universe } = action; - const world = World.createWorldFromEntireUniverse(universe); - const colorMode = null; - const colors = createColors(world, colorMode); - const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState( - state, - world - ); - const crossfilter = World.createObsDimensions( - new Crossfilter(world.obsAnnotations), - world - ); WorldUtil.clearCaches(); - return { ...state, loading: false, error: null, - universe, - fullUniverseCache: { world, crossfilter }, - world, - categoricalSelectionState, - crossfilter, - colorMode, - colorAccessor: null, - colors, resettingInterface: false }; } case "reset World to eq Universe": { - /* - 1. Reset world & crossfilter, using previously created objects which were - stashed in `fullUniverseCache` - 2. Add crossfilter dimension for all userDefined and diffexp genes/varData, - as they are not part of the cached crossfilter. - 3. Compute categorical selection summary - 4. Reset all WorldUtil caches - 5. Reset color-by - */ - const { userDefinedGenes, diffexpGenes, fullUniverseCache } = state; - const { world } = fullUniverseCache; - const crossfilter = ControlsHelpers.createGeneDimensions( - userDefinedGenes, - diffexpGenes, - world, - fullUniverseCache.crossfilter - ); - const colorMode = null; - const colors = createColors(world, colorMode); - const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState( - state, - world - ); WorldUtil.clearCaches(); return { ...state, - world, - categoricalSelectionState, - crossfilter, - colorMode, - colorAccessor: null, - colors, resettingInterface: false }; } case "set World to current selection": { - const { - userDefinedGenes, - diffexpGenes, - colorMode, - colorAccessor - } = state; - - /* Set viewable world to be the currently selected data */ - const world = World.createWorldFromCurrentSelection( - action.universe, - action.world, - action.crossfilter - ); - const colors = createColors(world, colorMode, colorAccessor); - const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState( - state, - world - ); - let crossfilter = new Crossfilter(world.obsAnnotations); - crossfilter = World.createObsDimensions(crossfilter, world); - crossfilter = ControlsHelpers.createGeneDimensions( - userDefinedGenes, - diffexpGenes, - world, - crossfilter - ); - WorldUtil.clearCaches(); - return { ...state, loading: false, - error: null, - world, - colors, - categoricalSelectionState, - crossfilter - }; - } - case "expression load success": { - const { world, universe } = state; - let universeVarData = universe.varData; - let worldVarData = world.varData; - - // Load new expression data into the varData dataframes, if - // not already present. - _.forEach(action.expressionData, (val, key) => { - // If not already in universe.varData, save entire expression column - if (!universeVarData.hasCol(key)) { - universeVarData = universeVarData.withCol(key, val); - } - - // If not already in world.varData, save sliced expression column - if (!worldVarData.hasCol(key)) { - // Slice if world !== universe, else just use whole column. - // Use the obsAnnotation index as the cut key, as we keep - // all world dataframes in sync. - let worldValSlice = val; - if (!World.worldEqUniverse(world, universe)) { - worldValSlice = universeVarData - .subset(world.obsAnnotations.rowIndex.keys(), [key], null) - .icol(0) - .asArray(); - } - - // Now build world's varData dataframe - worldVarData = worldVarData.withCol( - key, - worldValSlice, - world.obsAnnotations.rowIndex - ); - } - }); - - // Prune size of varData "cache" if getting out of hand.... - const { userDefinedGenes, diffexpGenes } = state; - const allTheGenesWeNeed = _.uniq( - [].concat( - userDefinedGenes, - diffexpGenes, - Object.keys(action.expressionData) - ) - ); - universeVarData = ControlsHelpers.pruneVarDataCache( - universeVarData, - allTheGenesWeNeed - ); - worldVarData = ControlsHelpers.pruneVarDataCache( - worldVarData, - allTheGenesWeNeed - ); - - return { - ...state, - universe: { - ...universe, - varData: universeVarData - }, - world: { - ...world, - varData: worldVarData - } + error: null }; } case "request user defined gene started": { @@ -269,108 +81,50 @@ const Controls = ( }; } case "request user defined gene success": { - const { world, crossfilter: oldCrossfilter, userDefinedGenes } = state; - const _userDefinedGenes = userDefinedGenes.slice(); - const gene = action.data.genes[0]; - - const crossfilter = oldCrossfilter.addDimension( - userDefinedDimensionName(gene), - "scalar", - world.varData.col(gene).asArray(), - Float32Array + const { userDefinedGenes } = state; + const _userDefinedGenes = _.uniq( + userDefinedGenes.concat(action.data.genes) ); return { ...state, - crossfilter, userDefinedGenes: _userDefinedGenes, userDefinedGenesLoading: false }; } case "request differential expression success": { - const { world, crossfilter: oldCrossfilter } = state; + const { world } = prevSharedState; const _diffexpGenes = []; - action.data.forEach(d => { _diffexpGenes.push(world.varAnnotations.at(d[0], "name")); }); - - let crossfilter = oldCrossfilter; - _.forEach(_diffexpGenes, gene => { - crossfilter = crossfilter.addDimension( - diffexpDimensionName(gene), - "scalar", - world.varData.col(gene).asArray(), - Float32Array - ); - }); - return { ...state, - crossfilter, diffexpGenes: _diffexpGenes }; } case "clear differential expression": { - const { world } = state; - let { crossfilter } = state; - _.forEach(action.diffExp, values => { - const name = world.varAnnotations.at(values[0], "name"); - crossfilter = crossfilter.delDimension(diffexpDimensionName(name)); - }); return { ...state, - crossfilter, diffexpGenes: [] }; } - case "user defined gene": { - /* - this could also live in expression success with a conditional, - but that handles diffexp also - */ - const newUserDefinedGenes = state.userDefinedGenes.slice(); - newUserDefinedGenes.push(action.data); - return { - ...state, - userDefinedGenes: newUserDefinedGenes - }; - } case "clear user defined gene": { - const { userDefinedGenes, crossfilter: oldCrossfilter } = state; + const { userDefinedGenes } = state; const newUserDefinedGenes = _.filter( userDefinedGenes, d => d !== action.data ); - const crossfilter = oldCrossfilter.delDimension( - userDefinedDimensionName(action.data) - ); return { ...state, - crossfilter, userDefinedGenes: newUserDefinedGenes }; } case "clear all user defined genes": { - const { userDefinedGenes } = state; - let { crossfilter } = state; - _.forEach(userDefinedGenes, gene => { - crossfilter = crossfilter.delDimension(userDefinedDimensionName(gene)); - }); return { ...state, - crossfilter, userDefinedGenes: [] }; } - case "reset colorscale": { - const { world } = state; - return { - ...state, - colorMode: null, - colorAccessor: null, - colors: createColors(world) - }; - } case "expression load error": case "initial data load error": { return { @@ -383,69 +137,6 @@ const Controls = ( /******************************* User Events *******************************/ - case "graph brush selection change": { - const name = layoutDimensionName("XY"); - const [x0, y0] = action.brushCoords.northwest; - const [x1, y1] = action.brushCoords.southeast; - const crossfilter = state.crossfilter.select(name, { - mode: "within-rect", - x0, - y0, - x1, - y1 - }); - return { - ...state, - crossfilter, - graphBrushSelection: action.brushCoords - }; - } - case "lasso deselect": - case "graph brush deselect": { - const name = layoutDimensionName("XY"); - const crossfilter = state.crossfilter.select(name, { mode: "all" }); - return { - ...state, - crossfilter, - graphBrushSelection: null - }; - } - case "lasso selection": { - const { polygon } = action; - const name = layoutDimensionName("XY"); - const { crossfilter: oldCrossfilter } = state; - let crossfilter; - if (polygon.length < 3) { - // single point or a line is not a polygon, and is therefore a deselect - crossfilter = oldCrossfilter.select(name, { mode: "all" }); - } else { - crossfilter = oldCrossfilter.select(name, { - mode: "within-polygon", - polygon - }); - } - return { - ...state, - crossfilter - }; - } - case "continuous metadata histogram brush": { - const name = makeContinuousDimensionName( - action.continuousNamespace, - action.selection - ); - let { crossfilter } = state; - - // action.selection: metadata name being selected - // action.range: filter range, or null if deselected - if (!action.range) { - crossfilter = crossfilter.select(name, { mode: "all" }); - } else { - const [lo, hi] = action.range; - crossfilter = crossfilter.select(name, { mode: "range", lo, hi }); - } - return { ...state, crossfilter }; - } case "change opacity deselected cells in 2d graph background": return { ...state, @@ -464,126 +155,6 @@ const Controls = ( resettingInterface: true }; } - /******************************* - Categorical metadata - *******************************/ - case "categorical metadata filter select": { - const newCategorySelected = Array.from( - state.categoricalSelectionState[action.metadataField].categorySelected - ); - newCategorySelected[action.categoryIndex] = true; - const newCategoricalSelectionState = { - ...state.categoricalSelectionState, - [action.metadataField]: { - ...state.categoricalSelectionState[action.metadataField], - categorySelected: newCategorySelected - } - }; - - // update the filter to match all selected options - const cat = newCategoricalSelectionState[action.metadataField]; - const dName = obsAnnoDimensionName(action.metadataField); - const crossfilter = state.crossfilter.select(dName, { - mode: "exact", - values: ControlsHelpers.selectedValuesForCategory(cat) - }); - return { - ...state, - categoricalSelectionState: newCategoricalSelectionState, - crossfilter - }; - } - case "categorical metadata filter deselect": { - const newCategorySelected = Array.from( - state.categoricalSelectionState[action.metadataField].categorySelected - ); - newCategorySelected[action.categoryIndex] = false; - const newCategoricalSelectionState = { - ...state.categoricalSelectionState, - [action.metadataField]: { - ...state.categoricalSelectionState[action.metadataField], - categorySelected: newCategorySelected - } - }; - - // update the filter to match all selected options - const cat = newCategoricalSelectionState[action.metadataField]; - const dName = obsAnnoDimensionName(action.metadataField); - const crossfilter = state.crossfilter.select(dName, { - mode: "exact", - values: ControlsHelpers.selectedValuesForCategory(cat) - }); - return { - ...state, - categoricalSelectionState: newCategoricalSelectionState, - crossfilter - }; - } - case "categorical metadata filter none of these": { - const newCategoricalSelectionState = { - ...state.categoricalSelectionState, - [action.metadataField]: { - ...state.categoricalSelectionState[action.metadataField], - categorySelected: Array.from( - state.categoricalSelectionState[action.metadataField] - .categorySelected - ).fill(false) - } - }; - const dName = obsAnnoDimensionName(action.metadataField); - const crossfilter = state.crossfilter.select(dName, { - mode: "none" - }); - return { - ...state, - categoricalSelectionState: newCategoricalSelectionState, - crossfilter - }; - } - case "categorical metadata filter all of these": { - const newCategoricalSelectionState = { - ...state.categoricalSelectionState, - [action.metadataField]: { - ...state.categoricalSelectionState[action.metadataField], - categorySelected: Array.from( - state.categoricalSelectionState[action.metadataField] - .categorySelected - ).fill(true) - } - }; - const dName = obsAnnoDimensionName(action.metadataField); - const crossfilter = state.crossfilter.select(dName, { - mode: "all" - }); - return { - ...state, - categoricalSelectionState: newCategoricalSelectionState, - crossfilter - }; - } - - /******************************* - Color Scale - *******************************/ - case "color by categorical metadata": - case "color by continuous metadata": { - const { world } = state; - return { - ...state, - colorMode: action.type, - colorAccessor: action.colorAccessor, - colors: createColors(world, action.type, action.colorAccessor) - }; - } - case "color by expression": { - const { world } = state; - return { - ...state, - colorMode: action.type, - colorAccessor: action.gene, - colors: createColors(world, action.type, action.gene) - }; - } /******************************* Scatterplot diff --git a/client/src/reducers/crossfilter.js b/client/src/reducers/crossfilter.js new file mode 100644 index 00000000..c9b2afcf --- /dev/null +++ b/client/src/reducers/crossfilter.js @@ -0,0 +1,189 @@ +import _ from "lodash"; + +import Crossfilter from "../util/typedCrossfilter"; +import { World, ControlsHelpers } from "../util/stateManager"; +import { + layoutDimensionName, + obsAnnoDimensionName, + userDefinedDimensionName, + diffexpDimensionName, + makeContinuousDimensionName +} from "../util/nameCreators"; + +const CrossfilterReducer = ( + state = null, + action, + nextSharedState, + prevSharedState +) => { + switch (action.type) { + case "initial data load complete (universe exists)": { + const { world } = nextSharedState; + const crossfilter = World.createObsDimensions( + new Crossfilter(world.obsAnnotations), + world + ); + return crossfilter; + } + + case "reset World to eq Universe": { + const { userDefinedGenes, diffexpGenes } = prevSharedState.controls; + const { world } = nextSharedState; + const crossfilter = ControlsHelpers.createGeneDimensions( + userDefinedGenes, + diffexpGenes, + world, + prevSharedState.resetCache.crossfilter + ); + return crossfilter; + } + + case "set World to current selection": { + const { userDefinedGenes, diffexpGenes } = prevSharedState.controls; + const { world } = nextSharedState; + let crossfilter = new Crossfilter(world.obsAnnotations); + crossfilter = World.createObsDimensions(crossfilter, world); + crossfilter = ControlsHelpers.createGeneDimensions( + userDefinedGenes, + diffexpGenes, + world, + crossfilter + ); + return crossfilter; + } + + case "request user defined gene success": { + const { world } = prevSharedState; + const gene = action.data.genes[0]; + return state.addDimension( + userDefinedDimensionName(gene), + "scalar", + world.varData.col(gene).asArray(), + Float32Array + ); + } + + case "request differential expression success": { + const { world } = prevSharedState; + const genes = _.map(action.data, d => + world.varAnnotations.at(d[0], "name") + ); + const crossfilter = _.reduce( + genes, + (xfltr, gene) => + xfltr.addDimension( + diffexpDimensionName(gene), + "scalar", + world.varData.col(gene).asArray(), + Float32Array + ), + state + ); + return crossfilter; + } + + case "clear differential expression": { + const { world } = prevSharedState; + const crossfilter = _.reduce( + action.diffExp, + (xfltr, values) => { + const name = world.varAnnotations.at(values[0], "name"); + return xfltr.delDimension(diffexpDimensionName(name)); + }, + state + ); + return crossfilter; + } + + case "clear user defined gene": { + return state.delDimension(userDefinedDimensionName(action.data)); + } + + case "clear all user defined genes": { + const { userDefinedGenes } = prevSharedState.controls; + const crossfilter = _.reduce( + userDefinedGenes, + (xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)), + state + ); + return crossfilter; + } + + case "graph brush selection change": { + const name = layoutDimensionName("XY"); + const [x0, y0] = action.brushCoords.northwest; + const [x1, y1] = action.brushCoords.southeast; + return state.select(name, { + mode: "within-rect", + x0, + y0, + x1, + y1 + }); + } + + case "lasso deselect": + case "graph brush deselect": { + const name = layoutDimensionName("XY"); + return state.select(name, { mode: "all" }); + } + + case "lasso selection": { + const { polygon } = action; + const name = layoutDimensionName("XY"); + if (polygon.length < 3) { + // single point or a line is not a polygon, and is therefore a deselect + return state.select(name, { mode: "all" }); + } + return state.select(name, { + mode: "within-polygon", + polygon + }); + } + + case "continuous metadata histogram start": + case "continuous metadata histogram brush": + case "continuous metadata histogram end": { + const name = makeContinuousDimensionName( + action.continuousNamespace, + action.selection + ); + // action.selection: metadata name being selected + // action.range: filter range, or null if deselected + if (!action.range) { + return state.select(name, { mode: "all" }); + } + const [lo, hi] = action.range; + const newState = state.select(name, { mode: "range", lo, hi }); + return newState; + } + + case "categorical metadata filter select": + case "categorical metadata filter deselect": { + const { categoricalSelection } = nextSharedState; + const cat = categoricalSelection[action.metadataField]; + return state.select(obsAnnoDimensionName(action.metadataField), { + mode: "exact", + values: ControlsHelpers.selectedValuesForCategory(cat) + }); + } + + case "categorical metadata filter none of these": { + return state.select(obsAnnoDimensionName(action.metadataField), { + mode: "none" + }); + } + + case "categorical metadata filter all of these": { + return state.select(obsAnnoDimensionName(action.metadataField), { + mode: "all" + }); + } + + default: { + return state; + } + } +}; + +export default CrossfilterReducer; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 8c61d76f..9d82b645 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -1,20 +1,86 @@ -// jshint esversion: 6 -import { combineReducers, createStore, applyMiddleware } from "redux"; +import { createStore, applyMiddleware } from "redux"; import thunk from "redux-thunk"; -import { composeWithDevTools } from "redux-devtools-extension"; +import cascadeReducers from "./cascade"; +import undoable from "./undoable"; import config from "./config"; +import universe from "./universe"; +import world from "./world"; +import categoricalSelection from "./categoricalSelection"; +import crossfilter from "./crossfilter"; +import colors from "./colors"; import differential from "./differential"; import responsive from "./responsive"; import controls from "./controls"; +import resetCache from "./resetCache"; -const Reducer = combineReducers({ - config, - responsive, - controls, - differential -}); +const ignoredActions = new Set([ + // these actions will not affect history, ie, we will + // not snapshot history upon these actions. These take + // precedent over `clearHistoryUponActions` + "url changed", + "interface reset started", + "initial data load start", + "configuration load complete", + "increment graph render counter", + "window resize", -const store = createStore(Reducer, composeWithDevTools(applyMiddleware(thunk))); + "lasso started", + + "request differential expression success", + + "expression load start", + "expression load success", + "expression load error", + + "continuous metadata histogram brush", + "continuous metadata histogram end", + + "request user defined gene started", + "request user defined gene success", + "request user defined gene error", + "bulk user defined gene complete", + "single user defined gene complete" +]); + +const clearOnActions = new Set([ + // history will be cleared when these actions occur + "initial data load complete (universe exists)", + "reset World to eq Universe", + "initial data load error" +]); + +/* configuration for the undoable meta reducer */ +const undoableConfig = { + historyLimit: 50, // maximum history size + skipActionFilter: (state, action) => ignoredActions.has(action.type), + clearOnActionFilter: (state, action) => clearOnActions.has(action.type) +}; + +const Reducer = undoable( + cascadeReducers([ + ["config", config], + ["universe", universe], + ["world", world], + ["categoricalSelection", categoricalSelection], + ["crossfilter", crossfilter], + ["colors", colors], + ["controls", controls], + ["differential", differential], + ["responsive", responsive], + ["resetCache", resetCache] + ]), + [ + "world", + "categoricalSelection", + "crossfilter", + "colors", + "controls", + "differential" + ], + undoableConfig +); + +const store = createStore(Reducer, applyMiddleware(thunk)); export default store; diff --git a/client/src/reducers/resetCache.js b/client/src/reducers/resetCache.js new file mode 100644 index 00000000..d2657bba --- /dev/null +++ b/client/src/reducers/resetCache.js @@ -0,0 +1,28 @@ +/* +Reducer which caches derived state to be used in a reset +*/ + +const ResetCacheReducer = ( + state = { + world: null, + crossfilter: null + }, + action, + nextSharedState +) => { + switch (action.type) { + case "initial data load complete (universe exists)": { + const { world, crossfilter } = nextSharedState; + return { + ...state, + world, + crossfilter + }; + } + default: { + return state; + } + } +}; + +export default ResetCacheReducer; diff --git a/client/src/reducers/undoable.js b/client/src/reducers/undoable.js new file mode 100644 index 00000000..4f6516c3 --- /dev/null +++ b/client/src/reducers/undoable.js @@ -0,0 +1,167 @@ +/* +A redo/undo meta reducer for Redux. Designed to work well with the cascadeReducer(). + +Requires three parameters: +* reducer - a reducer, which MUST return an object as state. +* undoableKeys - an array of object keys (strings). If any of these keys + are in the object/state returned by the reducer, they will be treated as + state to be made "undoable". +* options - an optional object, which may contain the following parameters: + * historyLimit: max number of historical states to remember (aka max undo depth) + * skipActionFilter: filter function, (state, action) => bool. If it returns + truthy, the current state will not be pushed onto the history stack. + * clearOnActionFilter: filter function, (state, action) => bool. If it returns + truthy, the history state will be cleared as part of handling this action. + +skipActionFilter has precedence over clearOnActionFilter. + +This meta reducer accepts three actions types: +* @@undoable/undo - move back in history +* @@undoable/redo - move forward in history +* @@undoable/clear - clear history +*/ + +const historyKeyPrefix = "@@undoable/"; +const pastKey = `${historyKeyPrefix}past`; +const futureKey = `${historyKeyPrefix}future`; +const defaultHistoryLimit = -100; + +const Undoable = (reducer, undoableKeys, options = {}) => { + let { historyLimit } = options; + if (!historyLimit) historyLimit = defaultHistoryLimit; + if (historyLimit > 0) historyLimit = -historyLimit; + const skipActionFilter = options.skipActionFilter || (() => false); + const clearOnActionFilter = options.clearOnActionFilter || (() => false); + + if (!Array.isArray(undoableKeys) || undoableKeys.length === 0) + throw new Error("undoable keys array must be specified"); + const undoableKeysSet = new Set(undoableKeys); + + function undo(currentState) { + const past = currentState[pastKey]; + const future = currentState[futureKey]; + if (past.length === 0) return currentState; + const currentUndoableState = Object.entries(currentState).filter(kv => + undoableKeysSet.has(kv[0]) + ); + const newPast = [...past]; + const newState = newPast.pop(); + const newFuture = push(future, currentUndoableState); + const nextState = { + ...currentState, + ...fromEntries(newState), + [pastKey]: newPast, + [futureKey]: newFuture + }; + return nextState; + } + + function redo(currentState) { + const past = currentState[pastKey] || []; + const future = currentState[futureKey] || []; + if (future.length === 0) return currentState; + const currentUndoableState = Object.entries(currentState).filter(kv => + undoableKeysSet.has(kv[0]) + ); + const newFuture = [...future]; + const newState = newFuture.pop(); + const newPast = push(past, currentUndoableState); + const nextState = { + ...currentState, + ...fromEntries(newState), + [pastKey]: newPast, + [futureKey]: newFuture + }; + return nextState; + } + + function clear(currentState) { + return { + ...currentState, + [pastKey]: [], + [futureKey]: [] + }; + } + + function skip(currentState, action) { + const past = currentState[pastKey] || []; + const res = reducer(currentState, action); + return { + ...res, + [pastKey]: past, + [futureKey]: [] + }; + } + + function save(currentState, action) { + const past = currentState[pastKey] || []; + const currentUndoableState = Object.entries(currentState).filter(kv => + undoableKeysSet.has(kv[0]) + ); + const res = reducer(currentState, action); + const newPast = push(past, currentUndoableState, historyLimit); + const nextState = { + ...res, + [pastKey]: newPast, + [futureKey]: [] + }; + return nextState; + } + + return ( + currentState = { + [pastKey]: [], + [futureKey]: [] + }, + action + ) => { + const aType = action.type; + switch (aType) { + case "@@undoable/undo": { + return undo(currentState, action); + } + case "@@undoable/redo": { + return redo(currentState, action); + } + case "@@undoable/clear": { + return clear(currentState, action); + } + default: { + if (skipActionFilter(currentState, action)) { + return skip(currentState, action); + } + if (clearOnActionFilter(currentState, action)) { + return clear(skip(currentState, action)); + } + return save(currentState, action); + } + } + }; +}; + +function push(arr, val, limit = undefined) { + /* + functional array push, with a max length limit to the new array. + Like Array.push, except it returns new array and discards as needed + to enforce the length limit. + */ + const narr = arr.slice(limit); + narr.push(val); + return narr; +} + +function fromEntries(arr) { + /* + Similar to Object.fromEntries, but only handles array. + This could be replaced with the standard fucnction once it + is widely available. As of 3/20/2019, it has not yet + been released in the Chrome stable channel. + */ + const obj = {}; + for (let i = 0, l = arr.length; i < l; i += 1) { + obj[arr[i][0]] = arr[i][1]; + } + return obj; +} + +export default Undoable; diff --git a/client/src/reducers/universe.js b/client/src/reducers/universe.js new file mode 100644 index 00000000..b5e0f13c --- /dev/null +++ b/client/src/reducers/universe.js @@ -0,0 +1,47 @@ +import _ from "lodash"; + +import { ControlsHelpers } from "../util/stateManager"; + +const Universe = (state = null, action, nextSharedState, prevSharedState) => { + switch (action.type) { + case "initial data load complete (universe exists)": { + const { universe } = action; + return universe; + } + + case "expression load success": { + let { varData } = state; + + // Load new expression data into the varData dataframes, if + // not already present. + _.forEach(action.expressionData, (val, key) => { + // If not already in universe.varData, save entire expression column + if (!varData.hasCol(key)) { + varData = varData.withCol(key, val); + } + }); + + // Prune size of varData "cache" if getting out of hand.... + const { userDefinedGenes, diffexpGenes } = prevSharedState; + const allTheGenesWeNeed = _.uniq( + [].concat( + userDefinedGenes, + diffexpGenes, + Object.keys(action.expressionData) + ) + ); + varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed); + + return { + ...state, + varData + }; + } + + default: { + return state; + } + } +}; + +export default Universe; diff --git a/client/src/reducers/world.js b/client/src/reducers/world.js new file mode 100644 index 00000000..faeb17d6 --- /dev/null +++ b/client/src/reducers/world.js @@ -0,0 +1,88 @@ +import _ from "lodash"; + +import { World, ControlsHelpers } from "../util/stateManager"; + +const WorldReducer = ( + state = null, + action, + nextSharedState, + prevSharedState +) => { + switch (action.type) { + case "initial data load complete (universe exists)": { + const { universe } = nextSharedState; + const world = World.createWorldFromEntireUniverse(universe); + return world; + } + + case "reset World to eq Universe": { + return prevSharedState.resetCache.world; + } + + case "set World to current selection": { + /* Set viewable world to be the currently selected data */ + const world = World.createWorldFromCurrentSelection( + action.universe, + action.world, + action.crossfilter + ); + return world; + } + + case "expression load success": { + const { universe } = nextSharedState; + const universeVarData = universe.varData; + let worldVarData = state.varData; + + // Load new expression data into the varData dataframes, if + // not already present. + _.forEach(action.expressionData, (val, key) => { + // If not already in world.varData, save sliced expression column + if (!worldVarData.hasCol(key)) { + // Slice if world !== universe, else just use whole column. + // Use the obsAnnotation index as the cut key, as we keep + // all world dataframes in sync. + let worldValSlice = val; + if (!World.worldEqUniverse(state, universe)) { + worldValSlice = universeVarData + .subset(state.obsAnnotations.rowIndex.keys(), [key], null) + .icol(0) + .asArray(); + } + + // Now build world's varData dataframe + worldVarData = worldVarData.withCol( + key, + worldValSlice, + state.obsAnnotations.rowIndex + ); + } + }); + + // Prune size of varData "cache" if getting out of hand.... + const { userDefinedGenes, diffexpGenes } = prevSharedState; + const allTheGenesWeNeed = _.uniq( + [].concat( + userDefinedGenes, + diffexpGenes, + Object.keys(action.expressionData) + ) + ); + worldVarData = ControlsHelpers.pruneVarDataCache( + worldVarData, + allTheGenesWeNeed + ); + + return { + ...state, + varData: worldVarData + }; + } + + default: { + return state; + } + } +}; + +export default WorldReducer; diff --git a/client/src/util/stateManager/controlsHelpers.js b/client/src/util/stateManager/controlsHelpers.js index 48b04544..e4c8e92e 100644 --- a/client/src/util/stateManager/controlsHelpers.js +++ b/client/src/util/stateManager/controlsHelpers.js @@ -10,7 +10,6 @@ import { userDefinedDimensionName, diffexpDimensionName } from "../nameCreators"; -import * as World from "./world"; /* Selection state for categoricals are tracked in an Object that @@ -55,7 +54,7 @@ function topNCategories(summary) { return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)]; } -export function createCategoricalSelectionState(state, world) { +export function createCategoricalSelection(maxCategoryItems, world) { const res = {}; _.forEach(world.obsAnnotations.colIndex.keys(), key => { const summary = world.obsAnnotations.col(key).summarize(); @@ -64,7 +63,7 @@ export function createCategoricalSelectionState(state, world) { const isSelectableCategory = !isColorField && key !== "name" && - summary.categories.length < state.maxCategoryItems; + summary.categories.length < maxCategoryItems; if (isSelectableCategory) { const [categoryValues, categoryCounts] = topNCategories(summary); const categoryIndices = new Map(categoryValues.map((v, i) => [v, i])); @@ -86,7 +85,7 @@ export function createCategoricalSelectionState(state, world) { } /* -given a categoricalSelectionState, return the list of all category values +given a categoricalSelection, return the list of all category values where selection state is true (ie, they are selected). */ export function selectedValuesForCategory(categorySelectionState) {