mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-05 01:08: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,374 @@
|
||||
/*
|
||||
Action creators for user annotation
|
||||
*/
|
||||
import _ from "lodash";
|
||||
import * as globals from "../globals";
|
||||
import { MatrixFBS, AnnotationsHelpers } from "../util/stateManager";
|
||||
|
||||
const { isUserAnnotation } = AnnotationsHelpers;
|
||||
|
||||
export const annotationCreateCategoryAction = (
|
||||
newCategoryName,
|
||||
categoryToDuplicate
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
Add a new user-created category to the obs annotations.
|
||||
|
||||
Arguments:
|
||||
newCategoryName - string name for the category.
|
||||
categoryToDuplicate - obs category to use for initial values, or null.
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
const { schema } = prevAnnoMatrix;
|
||||
|
||||
/* name must be a string, non-zero length */
|
||||
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
/* ensure the name isn't already in use! */
|
||||
if (schema.annotations.obsByName[newCategoryName])
|
||||
throw new Error("name collision on annotation category create");
|
||||
|
||||
let initialValue;
|
||||
let categories;
|
||||
if (categoryToDuplicate) {
|
||||
/* if we are duplicating a category, retrieve it */
|
||||
const catDupSchema = schema.annotations.obsByName[categoryToDuplicate];
|
||||
const catDupType = catDupSchema?.type;
|
||||
if (catDupType !== "string" && catDupType !== "categorical")
|
||||
throw new Error("categoryToDuplicate does not exist or has invalid type");
|
||||
|
||||
const catToDupDf = await prevAnnoMatrix
|
||||
.base()
|
||||
.fetch("obs", categoryToDuplicate);
|
||||
const col = catToDupDf.col(categoryToDuplicate);
|
||||
initialValue = col.asArray();
|
||||
({ categories } = col.summarize());
|
||||
} else {
|
||||
/* else assign to the standard default value */
|
||||
initialValue = globals.unassignedCategoryLabel;
|
||||
categories = [globals.unassignedCategoryLabel];
|
||||
}
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.addObsColumn(
|
||||
{
|
||||
name: newCategoryName,
|
||||
type: "categorical",
|
||||
categories,
|
||||
writable: true,
|
||||
},
|
||||
Array,
|
||||
initialValue
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: create category",
|
||||
data: newCategoryName,
|
||||
categoryToDuplicate,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationRenameCategoryAction = (
|
||||
oldCategoryName,
|
||||
newCategoryName
|
||||
) => (dispatch, getState) => {
|
||||
/*
|
||||
Rename a user-created annotation category
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, oldCategoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
/* name must be a string, non-zero length */
|
||||
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
if (oldCategoryName === newCategoryName) return;
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.renameObsColumn(
|
||||
oldCategoryName,
|
||||
newCategoryName
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: category edited",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: oldCategoryName,
|
||||
newCategoryText: newCategoryName,
|
||||
data: newCategoryName,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationDeleteCategoryAction = (categoryName) => (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
/*
|
||||
Delete a user-created category
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.dropObsColumn(categoryName);
|
||||
dispatch({
|
||||
type: "annotation: delete category",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: categoryName,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationCreateLabelInCategory = (
|
||||
categoryName,
|
||||
labelName,
|
||||
assignSelected
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
Add a new label to a user-defined category. If assignSelected is true, assign
|
||||
the label to all currently selected cells.
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
let obsCrossfilter = prevObsCrossfilter.addObsAnnoCategory(
|
||||
categoryName,
|
||||
labelName
|
||||
);
|
||||
if (assignSelected) {
|
||||
obsCrossfilter = await obsCrossfilter.setObsColumnValues(
|
||||
categoryName,
|
||||
prevObsCrossfilter.allSelectedLabels(),
|
||||
labelName
|
||||
);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: categoryName,
|
||||
newLabelText: labelName,
|
||||
assignSelectedCells: assignSelected,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationDeleteLabelFromCategory = (
|
||||
categoryName,
|
||||
labelName
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
delete a label from a user-defined category
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.removeObsAnnoCategory(
|
||||
categoryName,
|
||||
labelName,
|
||||
globals.unassignedCategoryLabel
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: delete label",
|
||||
metadataField: categoryName,
|
||||
label: labelName,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationRenameLabelInCategory = (
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
newLabelName
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
label name change
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
let obsCrossfilter = await prevObsCrossfilter.resetObsColumnValues(
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
newLabelName
|
||||
);
|
||||
obsCrossfilter = await obsCrossfilter.removeObsAnnoCategory(
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
globals.unassignedCategoryLabel
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
editedLabel: newLabelName,
|
||||
metadataField: categoryName,
|
||||
label: oldLabelName,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationLabelCurrentSelection = (
|
||||
categoryName,
|
||||
labelName
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
set the label on all currently selected
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.setObsColumnValues(
|
||||
categoryName,
|
||||
prevObsCrossfilter.allSelectedLabels(),
|
||||
labelName
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: label current cell selection",
|
||||
metadataField: categoryName,
|
||||
label: labelName,
|
||||
obsCrossfilter,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
});
|
||||
};
|
||||
|
||||
function writableAnnotations(annoMatrix) {
|
||||
return annoMatrix.schema.annotations.obs.columns
|
||||
.filter((s) => s.writable)
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
export const needToSaveObsAnnotations = (annoMatrix, lastSavedAnnoMatrix) => {
|
||||
/*
|
||||
Return true if there are LIKELY user-defined annotation modifications between the two
|
||||
annoMatrices. Technically not an action creator, but intimately intertwined
|
||||
with the save process.
|
||||
|
||||
Two conditions will trigger a need to save:
|
||||
* the collection of user-defined columns have changed
|
||||
* the contents of the user-defined columns have change
|
||||
*/
|
||||
|
||||
annoMatrix = annoMatrix.base();
|
||||
|
||||
// if the annoMatrix hasn't changed, we are guaranteed no changes to the matrix schema or contents.
|
||||
if (annoMatrix === lastSavedAnnoMatrix) return false;
|
||||
|
||||
// if the schema has changed, we need to save
|
||||
const currentWritable = writableAnnotations(annoMatrix);
|
||||
if (_.difference(currentWritable, writableAnnotations(lastSavedAnnoMatrix))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// no schema changes; check for change in contents
|
||||
return currentWritable.some(
|
||||
(col) => annoMatrix.col(col) !== lastSavedAnnoMatrix.col(col)
|
||||
);
|
||||
};
|
||||
|
||||
export const saveObsAnnotationsAction = () => async (dispatch, getState) => {
|
||||
/*
|
||||
Save the user-created obs annotations IF any have changed.
|
||||
*/
|
||||
const state = getState();
|
||||
const { annotations, autosave } = state;
|
||||
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
||||
const { lastSavedAnnoMatrix, saveInProgress } = autosave;
|
||||
|
||||
const annoMatrix = state.annoMatrix.base();
|
||||
|
||||
if (saveInProgress || annoMatrix === lastSavedAnnoMatrix) return;
|
||||
if (!needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix)) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
lastSavedAnnoMatrix: annoMatrix,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
Else, we really do need to save
|
||||
*/
|
||||
|
||||
dispatch({
|
||||
type: "writable obs annotations - save started",
|
||||
});
|
||||
|
||||
const df = await annoMatrix.fetch("obs", writableAnnotations(annoMatrix));
|
||||
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
||||
try {
|
||||
const queryString =
|
||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||
? `?annotation-collection-name=${encodeURIComponent(
|
||||
dataCollectionName
|
||||
)}`
|
||||
: "";
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: matrix,
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
}),
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
lastSavedAnnoMatrix: annoMatrix,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: `HTTP error ${res.status} - ${res.statusText}`,
|
||||
res,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: error.toString(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user