mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-21 10:18:12 +08:00
388 lines
11 KiB
JavaScript
388 lines
11 KiB
JavaScript
/*
|
|
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 newSchema;
|
|
let ctor;
|
|
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();
|
|
const { categories } = col.summarizeCategorical();
|
|
// all user-created annotations must have the unassigned category
|
|
if (!categories.includes(globals.unassignedCategoryLabel)) {
|
|
categories.push(globals.unassignedCategoryLabel);
|
|
}
|
|
ctor = initialValue.constructor;
|
|
newSchema = {
|
|
...catDupSchema,
|
|
name: newCategoryName,
|
|
categories,
|
|
writable: true,
|
|
};
|
|
} else {
|
|
/* else assign to the standard default value */
|
|
initialValue = globals.unassignedCategoryLabel;
|
|
ctor = Array;
|
|
newSchema = {
|
|
name: newCategoryName,
|
|
type: "categorical",
|
|
categories: [globals.unassignedCategoryLabel],
|
|
writable: true,
|
|
};
|
|
}
|
|
|
|
const obsCrossfilter = prevObsCrossfilter.addObsColumn(
|
|
newSchema,
|
|
ctor,
|
|
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,
|
|
});
|
|
}
|
|
};
|