Redux refactor (#1571)

* refactor categorical controls state

* lint

* fix race condition in tests

* fix typo

* add missing update on subset

* remove obsolete code

* update jest and puppeteer major version; update all minors

* update when label changes

* remove lint from tests; increase timeouts in e2e tests

* initial refactoring to new async annomatrix

* refine error handling

* fix bad merge

* add continuous legend

* lint

* fix memoization in color table creators

* partial implementation of user defined annotations

* add new annotations action creator file

* first pass at user annotations

* additional user annotation bug fixes

* user annotation auto-save

* unit test cleanup

* lint

* refactor into multiple files

* cleanup

* add column GC

* fix several bugs in user annotations

* remove debug code

* no anonymous functions

* undo redo cleanup

* file cleanup

* scatterplot

* performance

* cleanup

* remove old code

* render in parallel with load

* fix race condition

* simply graph rendering

* render throttle DRY

* fix category label order

* fix typo in e2e test setup

* re-fix the e2e test setup

* be more tolerant of races

* anno matrix unit tests

* temp disable reembedding

* pilot port continuous histo to react-async

* name change

* lint

* fix repaint bug

* typo fix

* update snap to match new ids

* world/universe name cleanup

* move annoMatrix to src dir

* use private underscore naming convention

* fix corner case in all selected

* name cleanup

* add layout control

* init edge case

* lint

* port scatterplot

* fix label indexing bug and improve tests

* port category to react-async

* fix user annotation labelling while subset

* select all of prev layout on layout switch

* fix race with crossfilter update

* prettier lint

* fix misleading comment

* fix url composition in loader

* first pass at crossfilter tests

* lint

* lint

* fix typo

* improved error handling for network errors

* fix memoization bug

* add memo

* refactor for performnce

* add missing single-value handling in select exact parser

* small bugs discovered by tests

* lint

* additional crossfilter unit tests

* remove extraneous comment

* add support for automatic category determination

* lint

* fix render bug in category

* take advantage of schema categories guarantee

* lint

* do not clear history when resetting

* enhanced annomatrix gc

* lint

* finish renaming to follow conventions; fix clone race bug

* lint

* add priority based loading to improve initial data load UX

* crossfilter cache perf

* perf tuning

* remove timers

* documentation

* PR review changes

* PR review changes

* more PR review edits

* improve clarity of comment

* more PR review fixes

* port centroidLabels to use react-async

* remove dead code

* pr review updates

* oops, remove logging
This commit is contained in:
Bruce Martin
2020-07-14 13:53:33 -07:00
committed by GitHub
parent f69d141336
commit 1269e188be
95 changed files with 18051 additions and 5382 deletions
+12
View File
@@ -0,0 +1,12 @@
/*
Reducer for the annoMatrix
*/
const AnnoMatrix = (state = null, action) => {
if (action.annoMatrix) {
return action.annoMatrix;
}
return state;
};
export default AnnoMatrix;
+7 -13
View File
@@ -2,22 +2,17 @@ const Autosave = (
state = {
saveInProgress: false,
error: false,
lastSavedObsAnnotations: null,
initialDataLoadComplete: false,
lastSavedAnnoMatrix: null,
},
action,
nextSharedState
action
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
/* don't save on init */
const { universe } = nextSharedState;
case "annoMatrix: init complete": {
return {
...state,
error: false,
saveInProgress: false,
lastSavedObsAnnotations: universe.obsAnnotations,
initialDataLoadComplete: true,
lastSavedAnnoMatrix: action.annoMatrix,
};
}
@@ -29,21 +24,20 @@ const Autosave = (
}
case "writable obs annotations - save error": {
const { message } = action;
return {
...state,
error: message,
error: action.message,
saveInProgress: false,
};
}
case "writable obs annotations - save complete": {
const lastSavedObsAnnotations = action.obsAnnotations;
const { lastSavedAnnoMatrix } = action;
return {
...state,
saveInProgress: false,
error: false,
lastSavedObsAnnotations,
lastSavedAnnoMatrix,
};
}
+11 -75
View File
@@ -13,92 +13,28 @@ Label state default (if missing) is up to the component, but typically true.
*/
const CategoricalSelection = (state, action, nextSharedState) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "set World to current selection":
case "reset World to eq Universe":
case "initial data load complete":
case "subset to selection":
case "reset subset":
case "set clip quantiles": {
const { world } = nextSharedState;
const { annoMatrix } = nextSharedState;
const newState = CH.createCategoricalSelection(
CH.selectableCategoryNames(world.schema)
CH.selectableCategoryNames(annoMatrix.schema)
);
return newState;
}
case "universe: column load success": {
const { dim } = action;
if (dim !== "obsAnnotations") return state;
const { dataframe } = action;
const { world } = nextSharedState;
const names = CH.selectableCategoryNames(
world.schema,
dataframe.colIndex.labels()
);
if (names.length === 0) return state;
case "categorical metadata filter select":
case "categorical metadata filter deselect":
case "categorical metadata filter none of these":
case "categorical metadata filter all of these": {
const { metadataField, labelSelectionState } = action;
return {
...state,
...CH.createCategoricalSelection(names),
[metadataField]: labelSelectionState,
};
}
case "categorical metadata filter select": {
/*
Set the specific category in this field to false
*/
const { metadataField, label } = action;
const newSelected = new Map(state[metadataField]);
newSelected.set(label, true);
const newCategoricalSelection = {
...state,
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
case "categorical metadata filter deselect": {
/*
Set the specific category in this field to false
*/
const { metadataField, label } = action;
const newSelected = new Map(state[metadataField]);
newSelected.set(label, false);
const newCategoricalSelection = {
...state,
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
case "categorical metadata filter none of these": {
/*
set all categories in this field to false.
*/
const { metadataField, labels } = action;
const { selected } = state[metadataField];
const newSelected = new Map(selected);
labels.forEach((label) => newSelected.set(label, false));
const newCategoricalSelection = {
...state,
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
case "categorical metadata filter all of these": {
/*
set all categories in this field to true.
*/
const { metadataField, labels } = action;
const { selected } = state[metadataField];
const newSelected = new Map(selected);
labels.forEach((label) => newSelected.set(label, true));
const newCategoricalSelection = {
...state,
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
case "annotation: create category": {
const name = action.data;
return {
+1 -38
View File
@@ -1,61 +1,24 @@
import calcCentroid from "../util/centroid";
const initialState = {
labels: [],
showLabels: false,
};
const centroidLabels = (state = initialState, action, sharedNextState) => {
const {
world,
layoutChoice,
categoricalSelection,
colors: { colorAccessor },
} = sharedNextState;
const showLabels = action.showLabels ?? state.showLabels;
switch (action.type) {
case "annotation: label current cell selection":
case "annotation: label edited":
case "annotation: delete label":
case "set layout choice":
case "set World to current selection":
case "reset World to eq Universe":
return {
...state,
labels:
!!colorAccessor && showLabels && !!categoricalSelection[colorAccessor]
? calcCentroid(world, colorAccessor, layoutChoice.currentDimNames)
: [],
};
case "color by categorical metadata":
case "show centroid labels for category":
// If colorby is not enabled or labels are not toggled to show
// then clear the labels and make sure the toggle is off
if (!colorAccessor || !showLabels) {
return {
...state,
labels: [],
showLabels,
};
}
return {
...state,
labels: calcCentroid(
world,
colorAccessor,
layoutChoice.currentDimNames
),
showLabels,
showLabels: colorAccessor && showLabels,
};
case "color by continuous metadata":
case "color by expression":
return { ...state, labels: [] };
case "reset centroid labels":
return initialState;
+16 -111
View File
@@ -1,32 +1,17 @@
import { ColorHelpers } from "../util/stateManager";
/*
Color By UI state
*/
const ColorsReducer = (
state = {
colorMode: null,
colorAccessor: null,
rgb: null,
scale: null,
},
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "universe exists, but loading is still in progress": {
/* initialize everything with default colors, no mode, no color-by accessor */
const { world } = nextSharedState;
const colorMode = null;
const colorAccessor = null;
const { rgb, scale } = ColorHelpers.createColors(world);
return {
...state,
colorAccessor,
colorMode,
rgb,
scale,
};
}
case "universe: user color load success": {
const { userColors } = action;
return {
@@ -35,46 +20,17 @@ const ColorsReducer = (
};
}
case "reset World to eq Universe": {
/* need to rebuild colors as world may have changed, but don't switch modes */
const { world } = nextSharedState;
const { colorMode, colorAccessor } = state;
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return {
...state,
rgb,
scale,
};
}
case "clear differential expression":
case "set clip quantiles":
case "set World to current selection": {
const { world: prevWorld, controls: prevControls } = prevSharedState;
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
prevControls,
state,
prevWorld
);
if (resetColorState) {
return resetColorState;
case "subset to selection": {
const { controls: prevControls } = prevSharedState;
if (prevControls.diffexpGenes.includes(state.colorAccessor)) {
return {
colorMode: null,
colorAccessor: null,
};
}
const { colorMode, colorAccessor } = state;
const { world } = nextSharedState;
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return {
...state,
rgb,
scale,
};
return state;
}
case "annotation: delete category": {
@@ -85,21 +41,21 @@ const ColorsReducer = (
/* else reset */
return {
...state,
...ColorHelpers.resetColors(prevSharedState.world),
colorMode: null,
colorAccessor: null,
};
}
case "reset colorscale": {
return {
...state,
...ColorHelpers.resetColors(prevSharedState.world),
colorMode: null,
colorAccessor: null,
};
}
case "color by categorical metadata":
case "color by continuous metadata": {
const { world, colors } = prevSharedState;
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode &&
@@ -107,78 +63,27 @@ const ColorsReducer = (
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.colorAccessor : null;
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor,
colors.userColors
);
return {
...state,
colorMode,
colorAccessor,
rgb,
scale,
};
}
case "color by expression": {
const { world } = prevSharedState;
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode && action.gene === state.colorAccessor;
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.gene : null;
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return {
...state,
colorMode,
colorAccessor,
rgb,
scale,
};
}
case "annotation: add new label to category":
case "annotation: label current cell selection":
case "annotation: delete label": {
const { world } = nextSharedState;
const { colorMode, colorAccessor } = state;
const { metadataField } = action;
if (
colorMode !== "color by categorical metadata" ||
colorAccessor !== metadataField
)
return state;
/* else, we need to rebuild colors as labels have changed! */
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return { ...state, rgb, scale };
}
case "clear differential expression": {
const { world: prevWorld, controls: prevControls } = prevSharedState;
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
prevControls,
state,
prevWorld
);
if (resetColorState) {
return resetColorState;
}
return state;
}
default: {
return state;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { makeContinuousDimensionName } from "../util/nameCreators";
const ContinuousSelection = (state = {}, action) => {
switch (action.type) {
case "reset World to eq Universe":
case "reset subset":
case "set clip quantiles": {
return {};
}
+6 -52
View File
@@ -21,9 +21,7 @@ const Controls = (
scatterplotYYaccessor: null,
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
},
action,
nextSharedState,
prevSharedState
action
) => {
/*
For now, log anything looking like an error to the console.
@@ -33,37 +31,10 @@ const Controls = (
}
switch (action.type) {
/*****************************************************
Initialization, World/Universe management
and data loading.
******************************************************/
case "initial data load start": {
return { ...state, loading: true };
}
case "universe: column load success": {
/*
we are in a loading state until the following are true:
* universe exists (if partially)
* embeddings (obsLayout) are loaded
* varAnnotations index is loaded
*/
const universeExists = !!nextSharedState.universe;
const embeddingsExist =
!nextSharedState.universe?.obsLayout?.isEmpty() ?? false;
const varIndex = nextSharedState.universe.schema.annotations.var.index;
const varAnnotationsIndexExists =
universeExists &&
nextSharedState.universe.varAnnotations.hasCol(varIndex);
return {
...state,
loading: !(
universeExists &&
embeddingsExist &&
varAnnotationsIndexExists
),
};
}
case "initial data load complete (universe exists)": {
case "initial data load complete": {
/* now fully loaded */
return {
...state,
@@ -72,7 +43,7 @@ const Controls = (
resettingInterface: false,
};
}
case "reset World to eq Universe": {
case "reset subset": {
const [newUserDefinedGenes, newDiffExpGenes] = subsetAndResetGeneLists(
state
);
@@ -83,7 +54,7 @@ const Controls = (
diffexpGenes: newDiffExpGenes,
};
}
case "set World to current selection": {
case "subset to selection": {
const [newUserDefinedGenes, newDiffExpGenes] = subsetAndResetGeneLists(
state
);
@@ -119,15 +90,10 @@ const Controls = (
};
}
case "request differential expression success": {
const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const _diffexpGenes = [];
action.data.forEach((d) => {
_diffexpGenes.push(world.varAnnotations.at(d[0], varIndexName));
});
const diffexpGenes = action.data.map((v) => v[0]);
return {
...state,
diffexpGenes: _diffexpGenes,
diffexpGenes,
};
}
case "clear differential expression": {
@@ -147,12 +113,6 @@ const Controls = (
userDefinedGenes: newUserDefinedGenes,
};
}
case "clear all user defined genes": {
return {
...state,
userDefinedGenes: [],
};
}
case "initial data load error": {
return {
...state,
@@ -181,12 +141,6 @@ const Controls = (
graphRenderCounter: c,
};
}
case "interface reset started": {
return {
...state,
resettingInterface: true,
};
}
/*******************************
Scatterplot
-324
View File
@@ -1,324 +0,0 @@
import _ from "lodash";
import Crossfilter from "../util/typedCrossfilter";
import {
World,
ControlsHelpers,
AnnotationsHelpers,
} from "../util/stateManager";
import {
layoutDimensionName,
obsAnnoDimensionName,
userDefinedDimensionName,
diffexpDimensionName,
makeContinuousDimensionName,
} from "../util/nameCreators";
const XYDimName = layoutDimensionName("XY");
const CrossfilterReducerBase = (
state = null,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "universe: column load success": {
const { world, layoutChoice } = nextSharedState;
const { obsAnnotations, obsLayout } = world;
// ignore var dimension loads as these are not currently selectable
if (action.dim === "varAnnotations") return state;
/*
during bootstrap loading, we don't know if obsLayout or obsAnnotations
will load first. Take whichever arrives and is not empty (so that our
crossfilter has the right dimensionality).
*/
let crossfilter =
state ??
new Crossfilter(obsAnnotations.isEmpty() ? obsLayout : obsAnnotations);
// add layout dimension, if not already present
if (
obsLayout.hasCol(layoutChoice.currentDimNames[0]) &&
!crossfilter.hasDimension(XYDimName)
) {
crossfilter = crossfilter.addDimension(
XYDimName,
"spatial",
obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
);
}
// add any missing obsAnnotations
crossfilter = World.addObsDimensions(crossfilter, world);
return crossfilter;
}
case "reset World to eq Universe": {
const { userDefinedGenes, diffexpGenes } = nextSharedState.controls;
const { world } = nextSharedState;
let { crossfilter } = prevSharedState.resetCache;
crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
);
crossfilter = AnnotationsHelpers.createWritableAnnotationDimensions(
world,
crossfilter
);
return crossfilter;
}
case "set clip quantiles":
case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = nextSharedState.controls;
const { world, layoutChoice } = nextSharedState;
let crossfilter = new Crossfilter(world.obsAnnotations);
crossfilter = World.createObsDimensions(
crossfilter,
world,
layoutChoice.currentDimNames
);
crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
);
return crossfilter;
}
case "set layout choice": {
/*
when switching layouts:
- delete the existing XY index
- add the new XY index (which implicitly selects all on it)
*/
const { world, layoutChoice } = nextSharedState;
return state
.delDimension(layoutDimensionName("XY"))
.addDimension(
layoutDimensionName("XY"),
"spatial",
world.obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
world.obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
);
}
case "request user defined gene success": {
const { world } = nextSharedState;
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 } = nextSharedState;
const varIndexName = world.schema.annotations.var.index;
const genes = _.map(action.data, (d) =>
world.varAnnotations.at(d[0], varIndexName)
);
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 } = nextSharedState;
const varIndexName = world.schema.annotations.var.index;
const crossfilter = _.reduce(
action.diffExp,
(xfltr, values) => {
const name = world.varAnnotations.at(values[0], varIndexName);
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 } = nextSharedState.controls;
const crossfilter = _.reduce(
userDefinedGenes,
(xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)),
state
);
return crossfilter;
}
case "annotation: create category": {
const name = action.data;
const { world } = nextSharedState;
const colData = world.obsAnnotations.col(name).asArray();
return state.addDimension(obsAnnoDimensionName(name), "enum", colData);
}
case "annotation: category edited": {
const name = action.metadataField;
const newName = action.newCategoryText;
return state.renameDimension(
obsAnnoDimensionName(name),
obsAnnoDimensionName(newName)
);
}
case "annotation: delete category": {
return state.delDimension(obsAnnoDimensionName(action.metadataField));
}
case "annotation: add new label to category":
case "annotation: label current cell selection":
case "annotation: label edited":
case "annotation: delete label": {
if (
action.type === "annotation: add new label to category" &&
!action.assignSelectedCells
)
return state;
/* we need to reindex the dimension. For now, just drop it and add another */
const name = action.metadataField;
const dimName = obsAnnoDimensionName(name);
const { world } = nextSharedState;
const colData = world.obsAnnotations.col(name).asArray();
return state.delDimension(dimName).addDimension(dimName, "enum", colData);
}
case "graph brush end":
case "graph brush change": {
const [minX, maxY] = action.brushCoords.northwest;
const [maxX, minY] = action.brushCoords.southeast;
return state.select(XYDimName, {
mode: "within-rect",
minX,
minY,
maxX,
maxY,
});
}
case "graph lasso end": {
const { polygon } = action;
return state.select(XYDimName, {
mode: "within-polygon",
polygon,
});
}
case "graph lasso cancel":
case "graph brush cancel":
case "graph lasso deselect":
case "graph brush deselect": {
return state.select(XYDimName, { mode: "all" });
}
case "continuous metadata histogram start":
case "continuous metadata histogram brush":
case "continuous metadata histogram cancel":
case "continuous metadata histogram end": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
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,
inclusive: true, // [lo, hi] incluisve selection
});
return newState;
}
case "categorical metadata filter select":
case "categorical metadata filter deselect": {
const { labels, metadataField } = action;
const selected = nextSharedState.categoricalSelection[metadataField];
const values = labels.filter((label) => selected.get(label) ?? true);
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact",
values,
});
}
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;
}
}
};
/*
IMPORTANT: the system assumes that crossfilter.data() will point at the
same value as world.obsAnnotations. For actions handled in this reducer,
make sure that this remains true.
This wrapper performs only this function.
*/
const CrossfilterReducer = (
state,
action,
nextSharedState,
prevSharedState
) => {
const nextState = CrossfilterReducerBase(
state,
action,
nextSharedState,
prevSharedState
);
/*
update the data in the crossfilter to point at the current obsAnnotations, IF
they are not empty. If empty, leave it alone (can occur during boostrap loading).
*/
const nextObsAnnotations = nextSharedState.world?.obsAnnotations;
if (
!nextState ||
nextState.all() === nextObsAnnotations ||
nextObsAnnotations.isEmpty()
) {
return nextState;
}
return nextState.setData(nextObsAnnotations);
};
export default CrossfilterReducer;
+2 -2
View File
@@ -47,8 +47,8 @@ const Differential = (
celllist1: null,
celllist2: null,
};
case "reset World to eq Universe":
case "set World to current selection":
case "reset subset":
case "subset to selection":
return {
...state,
diffExp: null,
+1 -1
View File
@@ -7,7 +7,7 @@ const GraphSelection = (
) => {
switch (action.type) {
case "set clip quantiles":
case "reset World to eq Universe":
case "reset subset":
case "set layout choice": {
return {
...state,
+8 -12
View File
@@ -4,31 +4,30 @@ import thunk from "redux-thunk";
import cascadeReducers from "./cascade";
import undoable from "./undoable";
import config from "./config";
import universe from "./universe";
import world from "./world";
import annoMatrix from "./annoMatrix";
import obsCrossfilter from "./obsCrossfilter";
import categoricalSelection from "./categoricalSelection";
import continuousSelection from "./continuousSelection";
import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter";
import colors from "./colors";
import differential from "./differential";
import layoutChoice from "./layoutChoice";
import controls from "./controls";
import resetCache from "./resetCache";
import annotations from "./annotations";
import autosave from "./autosave";
import ontology from "./ontology";
import centroidLabels from "./centroidLabels";
import pointDialation from "./pointDilation";
import { reembedController, reembedding } from "./reembed";
import { gcMiddleware as annoMatrixGC } from "../annoMatrix";
import undoableConfig from "./undoableConfig";
const Reducer = undoable(
cascadeReducers([
["config", config],
["universe", universe],
["world", world],
["annoMatrix", annoMatrix],
["obsCrossfilter", obsCrossfilter],
["ontology", ontology],
["annotations", annotations],
["reembedding", reembedding],
@@ -38,21 +37,18 @@ const Reducer = undoable(
["graphSelection", graphSelection],
["colors", colors],
["controls", controls],
["crossfilter", crossfilter],
["differential", differential],
["centroidLabels", centroidLabels],
["pointDilation", pointDialation],
["reembedController", reembedController],
["autosave", autosave],
["resetCache", resetCache],
]),
[
"universe",
"world",
"annoMatrix",
"obsCrossfilter",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"colors",
"controls",
"differential",
@@ -64,6 +60,6 @@ const Reducer = undoable(
undoableConfig
);
const store = createStore(Reducer, applyMiddleware(thunk));
const store = createStore(Reducer, applyMiddleware(thunk, annoMatrixGC));
export default store;
+7 -8
View File
@@ -14,8 +14,7 @@ function bestDefaultLayout(layouts) {
return layouts[0];
}
function setToDefaultLayout(world) {
const { schema } = world;
function setToDefaultLayout(schema) {
const available = schema.layout.obs.map((v) => v.name).sort();
const current = bestDefaultLayout(available);
const currentDimNames = schema.layout.obsByName[current].dims;
@@ -32,17 +31,17 @@ const LayoutChoice = (
nextSharedState
) => {
switch (action.type) {
case "universe exists, but loading is still in progress": {
case "initial data load complete": {
// set default to default
const { universe } = nextSharedState;
const { annoMatrix } = nextSharedState;
return {
...state,
...setToDefaultLayout(universe),
...setToDefaultLayout(annoMatrix.schema),
};
}
case "set layout choice": {
const { schema } = nextSharedState.world;
const { schema } = nextSharedState.annoMatrix;
const current = action.layoutChoice;
const currentDimNames = schema.layout.obsByName[current].dims;
return { ...state, current, currentDimNames };
@@ -58,9 +57,9 @@ const LayoutChoice = (
}
case "reembed: clear all reembeddings": {
const { universe } = nextSharedState;
const { annoMatrix } = nextSharedState;
const { current } = state;
const dflt = setToDefaultLayout(universe);
const dflt = setToDefaultLayout(annoMatrix.schema);
if (dflt.available.includes(current)) {
return {
...state,
+12
View File
@@ -0,0 +1,12 @@
/*
Reducer for the obsCrossfilter
*/
const ObsCrossfilter = (state = null, action) => {
if (action.obsCrossfilter) {
return action.obsCrossfilter;
}
return state;
};
export default ObsCrossfilter;
-31
View File
@@ -1,31 +0,0 @@
/*
Reducer which caches derived state to be used in a reset or other
recomputation. Add stuff here you want stashed at init time (or whenever),
for later use.
Currently this only caches the baseline (full universe) crossfilter,
which improves Reset UI performance.
*/
const ResetCacheReducer = (
state = {
crossfilter: null,
},
action,
nextSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "set layout choice": {
const { crossfilter } = nextSharedState;
return {
...state,
crossfilter,
};
}
default: {
return state;
}
}
};
export default ResetCacheReducer;
+3 -15
View File
@@ -8,12 +8,10 @@ const stateKey = "@@undoable/filterState";
these actions will not affect history
*/
const skipOnActions = new Set([
"annoMatrix: init complete",
"url changed",
"interface reset started",
"initial data load start",
"universe: column load success",
"universe: user color load success",
"universe exists, but loading is still in progress",
"configuration load complete",
"increment graph render counter",
"window resize",
@@ -23,16 +21,7 @@ const skipOnActions = new Set([
"graph brush change",
"continuous metadata histogram brush",
"expression load start",
"expression load success",
"expression load error",
"request user defined gene started",
"request user defined gene success",
"clear all user defined genes",
"get single gene expression for coloring started",
"get single gene expression for coloring error",
"category value mouse hover start",
"category value mouse hover end",
@@ -62,8 +51,7 @@ const debounceOnActions = new Set([]);
history will be cleared when these actions occur
*/
const clearOnActions = new Set([
"initial data load complete (universe exists)",
"reset World to eq Universe",
"initial data load complete",
"initial data load error",
]);
@@ -88,7 +76,7 @@ const saveOnActions = new Set([
"store current cell selection as differential set 1",
"store current cell selection as differential set 2",
"set World to current selection",
"subset to selection",
"set clip quantiles",
"set layout choice",
-308
View File
@@ -1,308 +0,0 @@
import { unassignedCategoryLabel } from "../globals";
import {
addObsAnnotations,
addVarAnnotations,
addObsLayout,
} from "../util/stateManager/universe";
import {
World,
ControlsHelpers,
AnnotationsHelpers,
} from "../util/stateManager";
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
switch (action.type) {
case "universe exists, but loading is still in progress": {
const { universe } = action;
return universe;
}
case "universe: column load success": {
const { dim, dataframe } = action;
switch (dim) {
case "obsAnnotations": {
return {
...state,
...addObsAnnotations(state, dataframe),
};
}
case "varAnnotations": {
return {
...state,
...addVarAnnotations(state, dataframe),
};
}
case "obsLayout": {
return {
...state,
...addObsLayout(state, dataframe),
};
}
default: {
throw new Error("action handler not implemented");
}
}
}
case "expression load success": {
let { varData } = state;
// Lazy load new expression data into the varData dataframe, if
// not already present.
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// 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 = [
...new Set(
[userDefinedGenes, diffexpGenes, Object.keys(action.expressionData)]
.filter((ele) => ele)
.flat()
),
];
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
return { ...state, varData };
}
case "annotation: create category": {
/* create a new annotation category, with all values set to 'unassigned' */
const name = action.data;
const { categoryToDuplicate } = action;
/* name must be a string, non-zero length */
if (typeof name !== "string" || name.length === 0)
throw new Error("user annotations require string name");
/* ensure the name isn't already in use! */
if (state.obsAnnotations.hasCol(name))
throw new Error("name collision on annotation category create");
/* ensure the duplicate col exists */
if (
categoryToDuplicate &&
!state.obsAnnotations.hasCol(categoryToDuplicate)
)
throw new Error("categoryToDuplicate does not exist");
let schema;
let data;
if (categoryToDuplicate) {
/* duplicate the named annotation */
schema = AnnotationsHelpers.dupObsAnnoSchema(
state.schema,
categoryToDuplicate,
name,
{
writable: true,
}
);
/* if we are duplicating a non-writable annotation, it may not have an unassigned category */
const s = schema.annotations.obsByName[name];
if (s.categories.indexOf(unassignedCategoryLabel) === -1) {
s.categories = s.categories.concat(unassignedCategoryLabel);
}
data = state.obsAnnotations.col(categoryToDuplicate).asArray();
} else {
/* else, all are unassined */
const categories = [unassignedCategoryLabel];
schema = AnnotationsHelpers.addObsAnnoSchema(state.schema, name, {
name,
categories,
type: "categorical",
writable: true,
});
data = new Array(state.nObs).fill(unassignedCategoryLabel);
}
const obsAnnotations = state.obsAnnotations.withCol(name, data);
return { ...state, obsAnnotations, schema };
}
case "annotation: category edited": {
/* change the name of an obs annotation category */
const name = action.metadataField;
const newName = action.newCategoryText;
if (!AnnotationsHelpers.isUserAnnotation(state, name))
throw new Error("unable to edit read-only annotation");
if (typeof newName !== "string" || newName.length === 0)
throw new Error("user annotations require string name");
const colSchema = {
...state.schema.annotations.obsByName[name],
name: newName,
};
const schema = AnnotationsHelpers.addObsAnnoSchema(
AnnotationsHelpers.removeObsAnnoSchema(state.schema, name),
newName,
colSchema
);
const obsAnnotations = state.obsAnnotations.renameCol(name, newName);
return { ...state, schema, obsAnnotations };
}
case "annotation: delete category": {
/* delete annotation category from schema and obsAnnotations */
const name = action.metadataField;
if (!AnnotationsHelpers.isUserAnnotation(state, name))
throw new Error("unable to delete read-only annotation");
const schema = AnnotationsHelpers.removeObsAnnoSchema(state.schema, name);
const obsAnnotations = state.obsAnnotations.dropCol(name);
return { ...state, schema, obsAnnotations };
}
case "annotation: add new label to category": {
const annotationName = action.metadataField;
const newLabelName = action.newLabelText;
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
throw new Error("unable to modify read-only annotation");
if (typeof newLabelName !== "string" || newLabelName.length === 0)
throw new Error(
"user annotations require a non-zero length string name"
);
/* add the new label to the annotation schema */
const schema = AnnotationsHelpers.addObsAnnoCategory(
state.schema,
annotationName,
newLabelName
);
/* if so requested, label the current selection */
const { world, crossfilter } = prevSharedState;
const { metadataField, newLabelText } = action;
const obsAnnotations = !action.assignSelectedCells
? state.obsAnnotations
: setLabelOnCurrentSelection(
state,
world,
crossfilter,
metadataField,
newLabelText
);
return { ...state, schema, obsAnnotations };
}
case "annotation: label edited": {
const annotationName = action.metadataField;
const oldLabelName = action.label;
const newLabelName = action.editedLabel;
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
throw new Error("unable to modify read-only annotation");
if (typeof newLabelName !== "string" || newLabelName.length === 0)
throw new Error(
"user annotations require a non-zero length string name"
);
/* remove old label, add new label */
const schema = AnnotationsHelpers.addObsAnnoCategory(
AnnotationsHelpers.removeObsAnnoCategory(
state.schema,
annotationName,
oldLabelName
),
annotationName,
newLabelName
);
/* change all values in obsAnnotation */
const obsAnnotations = AnnotationsHelpers.setLabelByValue(
state.obsAnnotations,
annotationName,
oldLabelName,
newLabelName
);
return { ...state, schema, obsAnnotations };
}
case "annotation: delete label": {
/* delete the label from the annotation, and set all cells with this value to unassigned */
const annotationName = action.metadataField;
const labelName = action.label;
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
throw new Error("unable to modify read-only annotation");
if (labelName === unassignedCategoryLabel)
throw new Error("may not remove the unassigned label");
/* remove the category from the schema */
const schema = AnnotationsHelpers.removeObsAnnoCategory(
state.schema,
annotationName,
labelName
);
/* set all values to unassigned in obsAnnotations */
const obsAnnotations = AnnotationsHelpers.setLabelByValue(
state.obsAnnotations,
annotationName,
labelName,
unassignedCategoryLabel
);
return { ...state, schema, obsAnnotations };
}
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { world, crossfilter } = prevSharedState;
const obsAnnotations = setLabelOnCurrentSelection(
state,
world,
crossfilter,
metadataField,
label
);
return { ...state, obsAnnotations };
}
default: {
return state;
}
}
};
function setLabelOnCurrentSelection(
universe,
world,
crossfilter,
metadataField,
label
) {
/*
Set category `metadataField` to value `label` for anything currently selected.
Used by several action type reducers.
Returns the new obsAnnotations dataframe.
*/
/*
selection state is relative to world. We need to convert it
to a mask for Universe before applying it.
*/
const worldMask = crossfilter.allSelectedMask();
const mask = World.worldEqUniverse(world, universe)
? worldMask
: AnnotationsHelpers.worldToUniverseMask(
worldMask,
world.obsAnnotations,
universe.nObs
);
const obsAnnotations = AnnotationsHelpers.setLabelByMask(
universe.obsAnnotations,
metadataField,
mask,
label
);
return obsAnnotations;
}
export default Universe;
-370
View File
@@ -1,370 +0,0 @@
import { unassignedCategoryLabel } from "../globals";
import {
World,
ControlsHelpers,
AnnotationsHelpers,
} from "../util/stateManager";
import {
addObsLayout,
removeObsLayout,
} from "../util/stateManager/schemaHelpers";
import clip from "../util/clip";
import quantile from "../util/quantile";
/*
important note: much of this code assumes that wriable (user) annotations
will NOT contain scalar data (ie, will only contain categorical labelled
data), and therefore will never need to be clipped. Put another way, it
assumes that for these annotations, the clipped & unclipped data is equal.
If we ever start allowing user editable scalar data, this assumption will
need to be revisited.
*/
const WorldReducer = (
state = null,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "universe exists, but loading is still in progress":
case "reset World to eq Universe": {
const { universe } = nextSharedState;
const world = World.createWorldFromEntireUniverse(universe);
return world;
}
case "universe: column load success": {
/* incremental initial data load - always assumes world == universe */
const { universe } = nextSharedState;
const { dim } = action;
// we don't clip anything except for varData and obsAnnotations
let { unclipped } = state;
if (dim === "varData" || dim === "obsAnnotations") {
unclipped = {
...unclipped,
[dim]: universe[dim].clone(),
};
}
return {
...state,
schema: universe.schema,
[dim]: universe[dim].clone(),
unclipped,
};
}
case "set World to current selection": {
/* Set viewable world to be the currently selected data */
const world = World.createWorldBySelection(
action.universe,
action.world,
action.crossfilter
);
return world;
}
case "set clip quantiles": {
const world = World.createWorldWithNewClip(
prevSharedState.universe,
state,
prevSharedState.crossfilter,
action.clipQuantiles
);
return world;
}
case "expression load success": {
const { universe } = nextSharedState;
const universeVarData = universe.varData;
let unclippedVarData = state.unclipped.varData;
// Lazy load new expression data into the unclipped varData dataframe, if
// not already present.
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// If not already in world.varData, save sliced expression column
if (!unclippedVarData.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.labels(), [key], null)
.icol(0)
.asArray();
}
// Now build world's varData dataframe
unclippedVarData = unclippedVarData.withCol(
key,
worldValSlice,
state.obsAnnotations.rowIndex
);
}
});
// Prune size of varData unclipped dataframe if getting out of hand....
//
const { userDefinedGenes, diffexpGenes } = prevSharedState;
const allTheGenesWeNeed = [
...new Set(
[userDefinedGenes, diffexpGenes, Object.keys(action.expressionData)]
.filter((ele) => ele)
.flat()
),
];
unclippedVarData = ControlsHelpers.pruneVarDataCache(
unclippedVarData,
allTheGenesWeNeed
);
// at this point, we have the unclipped data in unclippedVarData.
// Now create clipped.
// - Drop columns no longer needed
// - Add new columns
//
let clippedVarData = state.varData;
const keysToDrop = clippedVarData.colIndex
.labels()
.filter((k) => !unclippedVarData.hasCol(k));
const keysToAdd = unclippedVarData.colIndex
.labels()
.filter((k) => !clippedVarData.hasCol(k));
keysToDrop.forEach((k) => {
clippedVarData = clippedVarData.dropCol(k);
});
keysToAdd.forEach((k) => {
const data = unclippedVarData.col(k).asArray();
const q = [state.clipQuantiles.min, state.clipQuantiles.max];
const [qMinVal, qMaxVal] = quantile(q, data);
const clippedData = clip(data, qMinVal, qMaxVal, Number.NaN);
clippedVarData = clippedVarData.withCol(
k,
clippedData,
state.obsAnnotations.rowIndex
);
});
return {
...state,
varData: clippedVarData,
unclipped: {
...state.unclipped,
varData: unclippedVarData,
},
};
}
case "annotation: create category": {
const name = action.data;
const { universe } = nextSharedState;
const { schema } = universe;
/*
if world !== universe, we have to subset the newly created annotation,
else, just use it as is.
*/
let newAnnotation = null;
if (!World.worldEqUniverse(state, universe)) {
newAnnotation = universe.obsAnnotations
.subset(state.obsAnnotations.rowIndex.labels(), [name], null)
.icol(0)
.asArray();
} else {
newAnnotation = universe.obsAnnotations.col(name).asArray();
}
const obsAnnotations = state.obsAnnotations.withCol(
name,
newAnnotation,
state.obsAnnotations.rowIndex
);
const unclipped = {
...state.unclipped,
obsAnnotations: state.unclipped.obsAnnotations.withCol(
name,
newAnnotation,
state.unclipped.obsAnnotations.rowIndex
),
};
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: category edited": {
/* change the name of an obs annotation */
const name = action.metadataField;
const newName = action.newCategoryText;
const { schema } = nextSharedState.universe;
const obsAnnotations = state.obsAnnotations.renameCol(name, newName);
const unclipped = {
...state.unclipped,
obsAnnotations: state.unclipped.obsAnnotations.renameCol(name, newName),
};
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: delete category": {
/* remove a category from obs annotation */
const { schema } = nextSharedState.universe;
const name = action.metadataField;
const obsAnnotations = state.obsAnnotations.dropCol(name);
const unclipped = {
...state.unclipped,
obsAnnotations: state.unclipped.obsAnnotations.dropCol(name),
};
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: add new label to category": {
/* add a new label to the schema - schema updated by universe reducer, we just need to note it */
const { schema } = nextSharedState.universe;
const { metadataField, newLabelText } = action;
const { crossfilter } = prevSharedState;
if (action.assignSelectedCells) {
return {
...state,
schema,
...setLabelOnCurrentSelection(
state,
crossfilter,
metadataField,
newLabelText
),
};
}
return { ...state, schema };
}
case "annotation: label edited": {
const { schema } = nextSharedState.universe;
const { metadataField } = action;
const oldLabelName = action.label;
const newLabelName = action.editedLabel;
/* set all values to to new label */
const unclipped = {
...state.unclipped,
obsAnnotations: AnnotationsHelpers.setLabelByValue(
state.unclipped.obsAnnotations,
metadataField,
oldLabelName,
newLabelName
),
};
const obsAnnotations = state.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: delete label": {
const { schema } = nextSharedState.universe;
const { label, metadataField } = action;
/* set all values to unassigned in obsAnnotations */
const unclipped = {
...state.unclipped,
obsAnnotations: AnnotationsHelpers.setLabelByValue(
state.unclipped.obsAnnotations,
metadataField,
label,
unassignedCategoryLabel
),
};
const obsAnnotations = state.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { crossfilter } = prevSharedState;
return {
...state,
...setLabelOnCurrentSelection(state, crossfilter, metadataField, label),
};
}
case "reembed: add reembedding": {
// new embedding loaded, which *only* affects world's layout.
// It may be new, or it may replace a previous re-embedding.
const { obsLayout: origObsLayout, schema: origSchema } = state;
const { embedding, schema: embeddingSchema } = action;
const { dims } = embeddingSchema;
let obsLayout = origObsLayout;
let schema = origSchema;
// alias the names the server sent us, in case they were not the same as the schema
const embedingLabels = embedding.colIndex.labels();
const labels = {
[embedingLabels[0]]: dims[0],
[embedingLabels[1]]: dims[1],
};
obsLayout = obsLayout.withColsFrom(embedding, labels);
schema = addObsLayout(schema, embeddingSchema);
return {
...state,
obsLayout,
schema,
};
}
case "reembed: clear all reembeddings": {
// reembedding was cleared -- remove from layout
const { obsLayout: origObsLayout, schema: origSchema } = state;
const { reembedding } = prevSharedState;
let schema = origSchema;
let obsLayout = origObsLayout;
reembedding.reembeddings.forEach((emb, name) => {
const { dims } = emb.schema;
obsLayout = obsLayout.dropCol(dims[0]);
obsLayout = obsLayout.dropCol(dims[1]);
schema = removeObsLayout(schema, name);
});
return {
...state,
obsLayout,
schema,
};
}
default: {
return state;
}
}
};
function setLabelOnCurrentSelection(world, crossfilter, metadataField, label) {
/*
Set category `metadataField` to value `label` for anything currently selected.
Used by several action type reducers.
*/
const mask = crossfilter.allSelectedMask();
const unclipped = {
...world.unclipped,
obsAnnotations: AnnotationsHelpers.setLabelByMask(
world.unclipped.obsAnnotations,
metadataField,
mask,
label
),
};
const obsAnnotations = world.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { obsAnnotations, unclipped };
}
export default WorldReducer;