mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-07 07:38:11 +08:00
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:
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Reducer for the annoMatrix
|
||||
*/
|
||||
|
||||
const AnnoMatrix = (state = null, action) => {
|
||||
if (action.annoMatrix) {
|
||||
return action.annoMatrix;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default AnnoMatrix;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
Vendored
+6
-52
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Reducer for the obsCrossfilter
|
||||
*/
|
||||
|
||||
const ObsCrossfilter = (state = null, action) => {
|
||||
if (action.obsCrossfilter) {
|
||||
return action.obsCrossfilter;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default ObsCrossfilter;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user