+
-
-
- }
- disabled={this.isAddCurrentSelectionDisabled(
- metadataField,
- value
- )}
- />
- {displayString !== globals.unassignedCategoryLabel ? (
-
- ) : null}
- {displayString !== globals.unassignedCategoryLabel ? (
-
- ) : null}
-
- }
- >
-
-
+ {count}
- ) : null}
-
+
+
+ {isUserAnno ? (
+
+
+
+ }
+ disabled={this.isAddCurrentSelectionDisabled(
+ metadataField,
+ value
+ )}
+ />
+ {displayString !== globals.unassignedCategoryLabel ? (
+
+ ) : null}
+ {displayString !== globals.unassignedCategoryLabel ? (
+
+ ) : null}
+
+ }
+ >
+
+
+
+ ) : null}
+
+
);
}
diff --git a/client/src/reducers/annotations.js b/client/src/reducers/annotations.js
index 177cbf8a..82955260 100644
--- a/client/src/reducers/annotations.js
+++ b/client/src/reducers/annotations.js
@@ -31,7 +31,6 @@ const Annotations = (
) => {
switch (action.type) {
case "configuration load complete": {
- const DefaultDataCollectionName = null;
const dataCollectionName =
action.config.parameters?.["annotations-data-collection-name"] ?? null;
const dataCollectionNameIsReadOnly =
diff --git a/client/src/reducers/crossfilter.js b/client/src/reducers/crossfilter.js
index d4733a17..2ac594b2 100644
--- a/client/src/reducers/crossfilter.js
+++ b/client/src/reducers/crossfilter.js
@@ -165,9 +165,16 @@ const CrossfilterReducerBase = (
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);
diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js
index ffb21305..cf16be82 100644
--- a/client/src/reducers/index.js
+++ b/client/src/reducers/index.js
@@ -18,6 +18,7 @@ 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";
@@ -28,6 +29,7 @@ const Reducer = undoable(
["config", config],
["universe", universe],
["world", world],
+ ["ontology", ontology],
["annotations", annotations],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
@@ -45,15 +47,15 @@ const Reducer = undoable(
]),
[
"universe",
- "categoricalSelection",
"world",
+ "categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
- "layoutChoice",
+ "colors",
"controls",
"differential",
- "colors",
+ "layoutChoice",
"centroidLabels",
"annotations"
],
diff --git a/client/src/reducers/ontology.js b/client/src/reducers/ontology.js
new file mode 100644
index 00000000..cd6418ff
--- /dev/null
+++ b/client/src/reducers/ontology.js
@@ -0,0 +1,29 @@
+// jshint esversion: 6
+const Ontology = (
+ state = {
+ enabled: false, // are ontology terms enabled?
+ terms: null, // an array of term names, eg, ['cell', 'lung cell', ...]
+ termSet: null, // a Set object containing all terms, for fast lookup
+ loading: true
+ },
+ action
+) => {
+ switch (action.type) {
+ case "configuration load complete":
+ const enabled =
+ action.config?.parameters?.annotations_cell_ontology_enabled ?? false;
+ const terms = action.config?.parameters?.annotations_cell_ontology_terms;
+ const termSet = new Set(terms);
+ return {
+ ...state,
+ loading: false,
+ enabled,
+ terms,
+ termSet
+ };
+ default:
+ return state;
+ }
+};
+
+export default Ontology;
diff --git a/client/src/reducers/universe.js b/client/src/reducers/universe.js
index c8c17ac3..815a344a 100644
--- a/client/src/reducers/universe.js
+++ b/client/src/reducers/universe.js
@@ -136,13 +136,27 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
"user annotations require a non-zero length string name"
);
- /* add the new label to the annotation */
+ /* add the new label to the annotation schema */
const schema = AnnotationsHelpers.addObsAnnoCategory(
state.schema,
annotationName,
newLabelName
);
- return { ...state, schema };
+
+ /* 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": {
@@ -208,23 +222,11 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { world, crossfilter } = prevSharedState;
-
- /*
- 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, state)
- ? worldMask
- : AnnotationsHelpers.worldToUniverseMask(
- worldMask,
- world.obsAnnotations,
- state.nObs
- );
- const obsAnnotations = AnnotationsHelpers.setLabelByMask(
- state.obsAnnotations,
+ const obsAnnotations = setLabelOnCurrentSelection(
+ state,
+ world,
+ crossfilter,
metadataField,
- mask,
label
);
return { ...state, obsAnnotations };
@@ -236,4 +238,39 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
}
};
+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;
diff --git a/client/src/reducers/world.js b/client/src/reducers/world.js
index 52aa0f43..745cd7e8 100644
--- a/client/src/reducers/world.js
+++ b/client/src/reducers/world.js
@@ -196,6 +196,21 @@ const WorldReducer = (
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 };
}
@@ -246,21 +261,10 @@ const WorldReducer = (
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { crossfilter } = prevSharedState;
- const mask = crossfilter.allSelectedMask();
- const unclipped = {
- ...state.unclipped,
- obsAnnotations: AnnotationsHelpers.setLabelByMask(
- state.unclipped.obsAnnotations,
- metadataField,
- mask,
- label
- )
+ return {
+ ...state,
+ ...setLabelOnCurrentSelection(state, crossfilter, metadataField, label)
};
- const obsAnnotations = state.obsAnnotations.replaceColData(
- metadataField,
- unclipped.obsAnnotations.col(metadataField).asArray()
- );
- return { ...state, obsAnnotations, unclipped };
}
default: {
@@ -269,4 +273,27 @@ const WorldReducer = (
}
};
+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;
diff --git a/client/src/util/stateManager/annotationsHelpers.js b/client/src/util/stateManager/annotationsHelpers.js
index e55e2d3a..563da193 100644
--- a/client/src/util/stateManager/annotationsHelpers.js
+++ b/client/src/util/stateManager/annotationsHelpers.js
@@ -183,7 +183,7 @@ export function createWritableAnnotationDimensions(world, crossfilter) {
return crossfilter;
}
-const legalCharacters = /^(\w|[ .])+$/;
+const legalCharacters = /^(\w|[ .()-])+$/;
export function annotationNameIsErroneous(name) {
/*
Validate the name - return:
@@ -193,10 +193,9 @@ export function annotationNameIsErroneous(name) {
Tests:
0. must be string, non-null
1. no leading or trailing spaces
- 2. only accept alpha, numeric, underscore, period and space
+ 2. only accept alpha, numeric, underscore, period, parens, hyphen and space
3. no runs of multiple spaces
*/
-
if (name === "") {
return "empty-string";
}
diff --git a/server/app/scanpy_engine/scanpy_engine.py b/server/app/scanpy_engine/scanpy_engine.py
index a77bb48c..845f8e3b 100644
--- a/server/app/scanpy_engine/scanpy_engine.py
+++ b/server/app/scanpy_engine/scanpy_engine.py
@@ -60,6 +60,9 @@ class ScanpyEngine(CXGDriver):
"annotations": False,
"annotations_file": None,
"annotations_output_dir": None,
+ "annotations_cell_ontology_enabled": False,
+ "annotations_cell_ontology_obopath": None,
+ "annotations_cell_ontology_terms": None,
"backed": False,
"disable_diffexp": False,
"diffexp_may_be_slow": False,
@@ -71,6 +74,8 @@ class ScanpyEngine(CXGDriver):
"disable-diffexp": self.config["disable_diffexp"],
"diffexp-may-be-slow": self.config["diffexp_may_be_slow"],
"annotations": self.config["annotations"],
+ "annotations_cell_ontology_enabled": self.config["annotations_cell_ontology_enabled"],
+ "annotations_cell_ontology_terms": self.config["annotations_cell_ontology_terms"],
}
if self.config["annotations"]:
if uid is not None:
diff --git a/server/app/util/ontology.py b/server/app/util/ontology.py
new file mode 100644
index 00000000..1c205ca0
--- /dev/null
+++ b/server/app/util/ontology.py
@@ -0,0 +1,37 @@
+"""
+Load and parse ontologies - currently support OBO files only.
+"""
+import fsspec
+import fastobo
+import traceback # use built-in formatter for SyntaxError
+
+
+""" our default ontology is the PURL for the Cell Ontology. See http://www.obofoundry.org/ontology/cl.html """
+DefaultOnotology = "http://purl.obolibrary.org/obo/cl.obo"
+
+
+class OntologyLoadFailure(Exception):
+ pass
+
+
+def load_obo(path):
+ """ given a URI or path, return an array of term names """
+ if path is None:
+ path = DefaultOnotology
+
+ try:
+ with fsspec.open(path) as f:
+ obo = fastobo.iter(f)
+ terms = filter(lambda stanza: type(stanza) is fastobo.term.TermFrame, obo)
+ names = [tag.name for term in terms for tag in term if type(tag) is fastobo.term.NameClause]
+ return names
+
+ except FileNotFoundError as e:
+ raise OntologyLoadFailure(f"Unable to find OBO ontology path: {path}") from e
+
+ except SyntaxError as e:
+ msg = ''.join(traceback.format_exception_only(SyntaxError, e))
+ raise OntologyLoadFailure(msg) from e
+
+ except Exception as e:
+ raise OntologyLoadFailure(f"Error loading OBO file {path}") from e
diff --git a/server/cli/launch.py b/server/cli/launch.py
index c7f0f006..fd5216e2 100644
--- a/server/cli/launch.py
+++ b/server/cli/launch.py
@@ -15,6 +15,7 @@ from server.app.util.errors import ScanpyFileError
from server.app.util.utils import custom_format_warning
from server.utils.utils import find_available_port, is_port_available, sort_options
from server.app.util.data_locator import DataLocator
+from server.app.util.ontology import load_obo, OntologyLoadFailure
# anything bigger than this will generate a special message
BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB
@@ -94,6 +95,18 @@ def common_args(func):
help="Directory of where to save output annotations; filename will be specified in the application. "
"Incompatible with --annotations-input-file.",
)
+ @click.option(
+ "--experimental-annotations-ontology",
+ is_flag=True,
+ default=False,
+ show_default=True,
+ help="When creating annotations, optionally autocomplete names from ontology terms.",)
+ @click.option(
+ "--experimental-annotations-ontology-obo",
+ default=None,
+ show_default=True,
+ metavar="