({
- 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) {