Ontologies (#1110)

* add sample ontologies file

* add ontologies reducer

* Move select category to own component

* Dialog and Input factored out

* refactoring categorical, partway

* validationn

* anno

* suggest  populates input

* frontend for ontology working

* initial implementation of back-end support for ontologies

* edit is now dialog again

* autosuggest working on edit

* part way through create arbitrary label

* handle choice in function

* pass duplicate cat  prop

* editing works

* update test to match new CLI params

* fix occupancy alignment

* edit category as dialogue

* secondary button

* remove stubbed out ontologies

* add label setting upon new label creation

* Update legal characters for labels (#1119)

* Allow any term in the ontology (bypass legal name check)

* Add hyphens and parens to legal characters in names

* improve performance for large ontologies

* correctly handle case where ontologies are disabled

* fix logic error in CLI

Co-authored-by: Bruce Martin <bruce@chanzuckerberg.com>

* PR cleanup 1

* lint

* validate user generated labels

* finish hooking up connected suggest component

* protect against undefined callbacks

* Fix illegal characters error message

* break out npm run commands

* fix error detection on label edit

Co-authored-by: Bruce Martin <bruce@chanzuckerberg.com>
Co-authored-by: Sidney Bell <sidneymbell@users.noreply.github.com>
This commit is contained in:
Colin Megill
2020-01-23 17:04:17 -05:00
committed by GitHub
co-authored by Bruce Martin Sidney Bell
parent 8d725b1ad9
commit d48647a655
20 changed files with 964 additions and 532 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
"repository": "https://github.com/chanzuckerberg/cellxgene", "repository": "https://github.com/chanzuckerberg/cellxgene",
"scripts": { "scripts": {
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ", "backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
"backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations ", "backend-dev-anno-ontology": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations --experimental-annotations-ontology",
"backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations",
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", "build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"clean": "rimraf build", "clean": "rimraf build",
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js", "dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
@@ -0,0 +1,91 @@
import React from "react";
import { connect } from "react-redux";
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
}))
class AnnoDialog extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
isActive,
text,
title,
instruction,
cancelTooltipContent,
errorMessage,
validationError,
annoSelect,
annoInput,
handleCancel,
handleSubmit,
primaryButtonText,
secondaryButtonText,
handleSecondaryButtonSubmit
} = this.props;
return (
<Dialog icon="tag" title={title} isOpen={isActive} onClose={handleCancel}>
<form
onSubmit={e => {
e.preventDefault();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>{instruction}</p>
{annoInput || null}
<p
style={{
marginTop: 7,
visibility: validationError ? "visible" : "hidden",
color: Colors.ORANGE3
}}
>
{errorMessage}
</p>
</div>
{annoSelect || null}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content={cancelTooltipContent}>
<Button onClick={handleCancel}>Cancel</Button>
</Tooltip>
{handleSecondaryButtonSubmit && secondaryButtonText ? (
<Button
onClick={handleSecondaryButtonSubmit}
disabled={!text || validationError}
intent="none"
type="submit"
>
{secondaryButtonText}
</Button>
) : null}
<Button
onClick={handleSubmit}
disabled={!text || validationError}
intent="primary"
type="submit"
>
{primaryButtonText}
</Button>
</div>
</div>
</form>
</Dialog>
);
}
}
export default AnnoDialog;
@@ -0,0 +1,159 @@
import React from "react";
import { connect } from "react-redux";
import { InputGroup, MenuItem } from "@blueprintjs/core";
import { Suggest } from "@blueprintjs/select";
import fuzzysort from "fuzzysort";
const filterOntology = (query, ontology) =>
/* fires on load, once, and then for each character typed into the input */
fuzzysort.go(query, ontology, {
limit: 100,
threshold: -10000 // don't return bad results
});
const renderListItem = (fuzzySortResult, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
/* the fuzzysort wraps the object with other properties, like a score */
const geneName = fuzzySortResult.target;
return (
<MenuItem
active={modifiers.active}
disabled={modifiers.disabled}
data-testid={`suggest-menu-item-${geneName}`}
// Use of annotations in this way is incorrect and dataset specific.
// See https://github.com/chanzuckerberg/cellxgene/issues/483
// label={gene.n_counts}
key={geneName}
onClick={g =>
/* this fires when user clicks a menu item */
handleClick(g)
}
text={geneName}
/>
);
};
const AnnoSuggest = props => {
const {
ontologyLoading,
handleItemChange,
handleChoice,
ontology,
handleCreateArbitraryLabel,
handleTextChange,
isTextInvalid,
isTextInvalidErrorMessage
} = props;
return (
<Suggest
fill
resetOnSelect
closeOnSelect
resetOnClose
createNewItemFromQuery={() => {}}
createNewItemRenderer={userInputStr => {
return isTextInvalid?.(userInputStr) ? (
<MenuItem disabled text={isTextInvalidErrorMessage(userInputStr)} />
) : (
<MenuItem
icon="add"
text="Create a label not in the ontology"
active
onClick={() => {
handleCreateArbitraryLabel(userInputStr);
}}
shouldDismissPopover={false}
/>
);
}}
itemDisabled={ontologyLoading ? () => true : () => false}
noResults={<MenuItem disabled text="No matching ontology identifier" />}
onItemSelect={handleChoice}
initialContent={
<MenuItem disabled text="Enter an ontology identifier…" />
}
inputProps={{ "data-testid": "gene-search" }}
inputValueRenderer={t => t.target}
itemListPredicate={filterOntology}
onActiveItemChange={handleItemChange}
itemRenderer={renderListItem.bind(this)}
items={!ontologyLoading && ontology ? ontology : ["No ontology loaded"]}
popoverProps={{ minimal: true }}
onQueryChange={(s, e) => {
// undefined event means resetOnSelect
if (e !== undefined) handleTextChange?.(s);
}}
/>
);
};
const VanillaInput = props => {
const { text, handleTextChange } = props;
return (
<InputGroup
autoFocus
value={text}
intent="none"
onChange={e => handleTextChange?.(e.target.value)}
leftIcon="tag"
/>
);
};
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
world: state.world,
ontology: state.ontology?.terms,
ontologyLoading: state.ontology?.loading
}))
class AnnoInputs extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
useSuggest,
handleTextChange,
text,
ontologyLoading,
world,
handleChoice,
handleItemChange,
handleCreateArbitraryLabel,
ontology,
isTextInvalid,
isTextInvalidErrorMessage
} = this.props;
return (
<div>
{/* Le sigh https://github.com/palantir/blueprint/issues/2864 */}
{useSuggest ? (
<AnnoSuggest
world={world}
text={text}
handleChoice={handleChoice}
ontologyLoading={ontologyLoading}
handleCreateArbitraryLabel={handleCreateArbitraryLabel}
handleItemChange={handleItemChange}
ontology={ontology}
handleTextChange={handleTextChange}
isTextInvalid={isTextInvalid}
isTextInvalidErrorMessage={isTextInvalidErrorMessage}
/>
) : (
<VanillaInput text={text} handleTextChange={handleTextChange} />
)}
</div>
);
}
}
export default AnnoInputs;
@@ -0,0 +1,57 @@
import React from "react";
import { connect } from "react-redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
}))
class DuplicateCategorySelect extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
allCategoryNames,
categoryToDuplicate,
handleModalDuplicateCategorySelection
} = this.props;
return (
<div>
<p>
Optionally duplicate all labels & cell assignments from existing
category into new category:
</p>
<Select
items={
allCategoryNames ||
[] /* this is a placeholder, could be a subcomponent to avoid this */
}
filterable={false}
itemRenderer={(d, { handleClick }) => {
return <MenuItem onClick={handleClick} key={d} text={d} />;
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
handleModalDuplicateCategorySelection(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={categoryToDuplicate || "None (all cells 'unassigned')"}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
);
}
}
export default DuplicateCategorySelect;
+49 -102
View File
@@ -1,20 +1,13 @@
// jshint esversion: 6 // jshint esversion: 6
import React from "react"; import React from "react";
import _ from "lodash"; import { Button } from "@blueprintjs/core";
import {
Button,
Tooltip,
InputGroup,
Dialog,
Classes,
MenuItem,
Colors
} from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as globals from "../../globals"; import * as globals from "../../globals";
import Category from "./category"; import Category from "./category";
import { AnnotationsHelpers } from "../../util/stateManager"; import { AnnotationsHelpers } from "../../util/stateManager";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import AnnoSelect from "./annoSelect";
@connect(state => ({ @connect(state => ({
categoricalSelection: state.categoricalSelection, categoricalSelection: state.categoricalSelection,
@@ -102,13 +95,25 @@ class Categories extends React.Component {
"empty-string": "Blank names not allowed", "empty-string": "Blank names not allowed",
duplicate: "Name must be unique", duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed", "trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters": "Only alphanumeric, underscore and period allowed", "illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed" "multi-space-run": "Multiple consecutive spaces not allowed"
}; };
const errorMessage = errorMessageMap[err] ?? "error"; const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>; return <span>{errorMessage}</span>;
}; };
handleNewCategoryText = txt => {
this.setState({ newCategoryText: txt });
};
handleChoice = e => {
/* Blueprint Suggest format */
this.setState({ newCategoryText: e.target });
};
handleSuggestActiveItemChange = () => {};
render() { render() {
const { const {
createAnnoModeActive, createAnnoModeActive,
@@ -131,9 +136,39 @@ class Categories extends React.Component {
padding: globals.leftSidebarSectionPadding padding: globals.leftSidebarSectionPadding
}} }}
> >
<AnnoDialog
isActive={createAnnoModeActive}
title="Create new category"
instruction="New, unique category name:"
cancelTooltipContent="Close this dialog without creating a category."
primaryButtonText="Create new category"
text={newCategoryText}
validationError={this.categoryNameError(newCategoryText)}
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
handleSubmit={this.handleCreateUserAnno}
handleCancel={this.handleDisableAnnoMode}
annoInput={
<AnnoInputs
text={newCategoryText}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleNewCategoryText}
/>
}
annoSelect={
<AnnoSelect
handleModalDuplicateCategorySelection={
this.handleModalDuplicateCategorySelection
}
categoryToDuplicate={categoryToDuplicate}
allCategoryNames={allCategoryNames}
/>
}
/>
{/* READ ONLY CATEGORICAL FIELDS */} {/* READ ONLY CATEGORICAL FIELDS */}
{/* this is duplicative but flat, could be abstracted */} {/* this is duplicative but flat, could be abstracted */}
{_.map(allCategoryNames, catName => {allCategoryNames.map(catName =>
!schema.annotations.obsByName[catName].writable ? ( !schema.annotations.obsByName[catName].writable ? (
<Category <Category
key={catName} key={catName}
@@ -144,7 +179,7 @@ class Categories extends React.Component {
) : null ) : null
)} )}
{/* WRITEABLE FIELDS */} {/* WRITEABLE FIELDS */}
{_.map(allCategoryNames, catName => {allCategoryNames.map(catName =>
schema.annotations.obsByName[catName].writable ? ( schema.annotations.obsByName[catName].writable ? (
<Category <Category
key={catName} key={catName}
@@ -156,94 +191,6 @@ class Categories extends React.Component {
)} )}
{writableCategoriesEnabled ? ( {writableCategoriesEnabled ? (
<div> <div>
<Dialog
icon="tag"
title="Create new category"
isOpen={createAnnoModeActive}
onClose={this.handleDisableAnnoMode}
>
<form
onSubmit={e => {
e.preventDefault();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>New, unique category name:</p>
<InputGroup
autoFocus
value={newCategoryText}
intent={
this.categoryNameError(newCategoryText)
? "warning"
: "none"
}
onChange={e =>
this.setState({ newCategoryText: e.target.value })
}
leftIcon="tag"
/>
<p
style={{
marginTop: 7,
visibility: this.categoryNameError(newCategoryText)
? "visible"
: "hidden",
color: Colors.ORANGE3
}}
>
{this.categoryNameErrorMessage(newCategoryText)}
</p>
</div>
<p>
Optionally duplicate all labels & cell assignments from
existing category into new category:
</p>
<Select
items={allCategoryNames}
filterable={false}
itemRenderer={(d, { handleClick }) => {
return (
<MenuItem onClick={handleClick} key={d} text={d} />
);
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
this.handleModalDuplicateCategorySelection(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={
categoryToDuplicate || "None (all cells 'unassigned')"
}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Close this dialog without creating a category.">
<Button onClick={this.handleDisableAnnoMode}>
Cancel
</Button>
</Tooltip>
<Button
onClick={this.handleCreateUserAnno}
disabled={
!newCategoryText ||
this.categoryNameError(newCategoryText)
}
intent="primary"
type="submit"
>
Create new category
</Button>
</div>
</div>
</form>
</Dialog>
<Button onClick={this.handleEnableAnnoMode} intent="primary"> <Button onClick={this.handleEnableAnnoMode} intent="primary">
Create new category Create new category
</Button> </Button>
+110 -161
View File
@@ -6,28 +6,30 @@ import { Flipper, Flipped } from "react-flip-toolkit";
import { import {
Button, Button,
Tooltip, Tooltip,
InputGroup,
Menu, Menu,
Dialog,
MenuItem, MenuItem,
Popover, Popover,
Classes,
Icon, Icon,
Position, Position,
PopoverInteractionKind, PopoverInteractionKind,
Colors Colors
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import * as globals from "../../globals"; import * as globals from "../../globals";
import Value from "./value"; import Value from "./value";
import sortedCategoryLabels from "../../util/catLabelSort";
import { AnnotationsHelpers } from "../../util/stateManager"; import { AnnotationsHelpers } from "../../util/stateManager";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({ @connect(state => ({
colorAccessor: state.colors.colorAccessor, colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection, categoricalSelection: state.categoricalSelection,
annotations: state.annotations, annotations: state.annotations,
universe: state.universe universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
})) }))
class Category extends React.Component { class Category extends React.Component {
constructor(props) { constructor(props) {
@@ -91,14 +93,48 @@ class Category extends React.Component {
handleAddNewLabelToCategory = () => { handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state; const { newLabelText } = this.state;
dispatch({ dispatch({
type: "annotation: add new label to category", type: "annotation: add new label to category",
metadataField, metadataField,
newLabelText newLabelText,
assignSelectedCells: false
}); });
this.setState({ newLabelText: "" }); this.setState({ newLabelText: "" });
}; };
addLabelAndAssignCells = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: true
});
this.setState({ newLabelText: "" });
};
handleCreateArbitraryLabel = newLabelTextNotInOntology => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText: newLabelTextNotInOntology,
assignSelectedCells: false
});
this.setState({ newLabelText: "" });
};
handleCategoryEditTextChange = txt => {
this.setState({
newCategoryText: txt
});
};
activateEditCategoryMode = () => { activateEditCategoryMode = () => {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
@@ -154,63 +190,16 @@ class Category extends React.Component {
}; };
labelNameError = name => { labelNameError = name => {
/* const { metadataField, ontology, universe } = this.props;
return false if this is a LEGAL/acceptable category name or NULL/empty string, return isLabelErroneous(name, metadataField, ontology, universe.schema);
or return an error type.
*/
/* allow empty string */
if (name === "") return false;
/* check for label syntax errors */
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
if (error) return error;
/* disallow duplicates */
const { metadataField, universe } = this.props;
const { obsByName } = universe.schema.annotations;
if (obsByName[metadataField].categories.indexOf(name) !== -1)
return "duplicate";
/* otherwise, no error */
return false;
}; };
labelNameErrorMessage = name => { labelNameErrorMessage = name => {
const { metadataField } = this.props; const { metadataField, ontology, universe } = this.props;
const err = this.labelNameError(name); return labelErrorMessage(name, metadataField, ontology, universe.schema);
if (err === "duplicate") {
/* duplicate error is special cased because it has special formatting */
return (
<span>
<span style={{ fontStyle: "italic" }}>{name}</span> already exists
already exists within{" "}
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
</span>
);
}
if (err) {
/* all other errors - map code to human error message */
const errorMessageMap = {
"empty-string": "Blank names not allowed",
duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters":
"Only alphanumeric, underscore and period allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>;
}
/* no error, no message generated */
return null;
}; };
categoryNameErrorMessage = () => { categoryNameErrorMessage = () => {
const { newCategoryText } = this.state;
const err = this.editedCategoryNameError(); const err = this.editedCategoryNameError();
if (err === false) return null; if (err === false) return null;
@@ -219,7 +208,8 @@ class Category extends React.Component {
"empty-string": "Blank names not allowed", "empty-string": "Blank names not allowed",
duplicate: "Category name must be unique", duplicate: "Category name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed", "trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters": "Only alphanumeric, underscore and period allowed", "illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed" "multi-space-run": "Multiple consecutive spaces not allowed"
}; };
const errorMessage = errorMessageMap[err] ?? "error"; const errorMessage = errorMessageMap[err] ?? "error";
@@ -261,6 +251,15 @@ class Category extends React.Component {
return false; return false;
}; };
/* leaky to have both of these in multiple components */
handleChoice = e => {
this.setState({ newLabelText: e.target });
};
handleTextChange = text => {
this.setState({ newLabelText: text });
};
toggleAll() { toggleAll() {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
dispatch({ dispatch({
@@ -318,13 +317,13 @@ class Category extends React.Component {
colorAccessor, colorAccessor,
categoricalSelection, categoricalSelection,
isUserAnno, isUserAnno,
annotations annotations,
ontologyEnabled
} = this.props; } = this.props;
const { isTruncated } = categoricalSelection[metadataField]; const { isTruncated } = categoricalSelection[metadataField];
const cat = categoricalSelection[metadataField]; const cat = categoricalSelection[metadataField];
const optTuples = [...cat.categoryValueIndices]; const optTuples = [...cat.categoryValueIndices];
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
const allCategoryNames = _.keys(categoricalSelection);
return ( return (
<div <div
@@ -381,46 +380,30 @@ class Category extends React.Component {
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} /> <Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
) : null} ) : null}
{annotations.isEditingCategoryName && {metadataField}
annotations.categoryBeingEdited === metadataField ? (
<form <AnnoDialog
style={{ display: "inline-block" }} isActive={
onSubmit={e => { annotations.isEditingCategoryName &&
e.preventDefault(); annotations.categoryBeingEdited === metadataField
this.handleEditCategory(); }
}} title="Edit category name"
> instruction="New, unique category name:"
<InputGroup cancelTooltipContent="Close this dialog without editing this category."
style={{ position: "relative", top: -1 }} primaryButtonText="Edit category name"
ref={input => { text={newCategoryText}
this.editableCategoryInput = input; validationError={this.editedCategoryNameError(newCategoryText)}
}} errorMessage={this.categoryNameErrorMessage(newCategoryText)}
small handleSubmit={this.handleEditCategory}
autoFocus handleCancel={this.disableEditCategoryMode}
onChange={e => { annoInput={
this.setState({ <AnnoInputs
newCategoryText: e.target.value useSuggest={false}
}); text={newCategoryText}
}} handleTextChange={this.handleCategoryEditTextChange}
defaultValue={metadataField}
rightElement={
<Button
minimal
disabled={this.editedCategoryNameError()}
style={{ position: "relative", top: -1 }}
type="button"
icon="small-tick"
data-testclass="submitCategoryNameEdit"
data-testid="submitCategoryNameEdit"
onClick={this.handleEditCategory}
/>
}
/> />
{this.categoryNameErrorMessage()} }
</form> />
) : (
metadataField
)}
{isExpanded ? ( {isExpanded ? (
<FaChevronDown <FaChevronDown
@@ -438,71 +421,37 @@ class Category extends React.Component {
<div> <div>
{isUserAnno ? ( {isUserAnno ? (
<> <>
<Dialog <AnnoDialog
icon="tag" isActive={
title="Add new label"
isOpen={
annotations.isAddingNewLabel && annotations.isAddingNewLabel &&
annotations.categoryAddingNewLabel === metadataField annotations.categoryAddingNewLabel === metadataField
} }
onClose={this.disableAddNewLabelMode} title="Add new label to category"
> instruction="New, unique label name:"
<form cancelTooltipContent="Close this dialog without adding a label."
onSubmit={e => { primaryButtonText="Add"
e.preventDefault(); secondaryButtonText="Add label & assign currently selected cells"
this.handleAddNewLabelToCategory(); handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
}} text={newLabelText}
> validationError={this.labelNameError(newLabelText)}
<div className={Classes.DIALOG_BODY}> errorMessage={this.labelNameErrorMessage(newLabelText)}
<div style={{ marginBottom: 20 }}> handleSubmit={this.handleAddNewLabelToCategory}
<p>New, unique label name:</p> handleCancel={this.disableAddNewLabelMode}
<InputGroup annoInput={
autoFocus <AnnoInputs
value={newLabelText} useSuggest={ontologyEnabled}
intent={ text={newLabelText}
this.labelNameError(newLabelText) handleCreateArbitraryLabel={
? "warning" this.handleCreateArbitraryLabel
: "none" }
} handleItemChange={this.handleSuggestActiveItemChange}
onChange={e => handleChoice={this.handleChoice}
this.setState({ newLabelText: e.target.value }) handleTextChange={this.handleTextChange}
} isTextInvalid={this.labelNameError}
leftIcon="tag" isTextInvalidErrorMessage={this.labelNameErrorMessage}
/> />
<p }
style={{ />
marginTop: 7,
visibility: this.labelNameError(newLabelText)
? "visible"
: "hidden",
color: Colors.ORANGE3
}}
>
{this.labelNameErrorMessage(newLabelText)}
</p>
</div>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Close this dialog without adding a label.">
<Button onClick={this.disableAddNewLabelMode}>
Cancel
</Button>
</Tooltip>
<Button
disabled={
!newLabelText || this.labelNameError(newLabelText)
}
onClick={this.handleAddNewLabelToCategory}
intent="primary"
type="submit"
>
Add new label to category
</Button>
</div>
</div>
</form>
</Dialog>
<Popover <Popover
interactionKind={PopoverInteractionKind.HOVER} interactionKind={PopoverInteractionKind.HOVER}
boundary="window" boundary="window"
@@ -0,0 +1,57 @@
import React from "react";
import { AnnotationsHelpers } from "../../util/stateManager";
export function isLabelErroneous(label, metadataField, ontology, schema) {
/*
return false if this is a LEGAL/acceptable category name or NULL/empty string,
or return an error type.
*/
/* allow empty string */
if (label === "") return false;
/* check for label syntax errors, but allow terms in ontology */
const termInOntology = ontology?.termSet.has(label) ?? false;
const error = AnnotationsHelpers.annotationNameIsErroneous(label);
if (error && !termInOntology) return error;
/* disallow duplicates */
const { obsByName } = schema.annotations;
if (obsByName[metadataField].categories.indexOf(label) !== -1)
return "duplicate";
/* otherwise, no error */
return false;
}
export function labelErrorMessage(label, metadataField, ontology, schema) {
const err = isLabelErroneous(label, metadataField, ontology, schema);
if (err === "duplicate") {
/* duplicate error is special cased because it has special formatting */
return (
<span>
<span style={{ fontStyle: "italic" }}>{label}</span> already
exists already exists within{" "}
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
</span>
);
}
if (err) {
/* all other errors - map code to human error message */
const errorMessageMap = {
"empty-string": "Blank names not allowed",
duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>;
}
/* no error, no message generated */
return null;
}
+215 -228
View File
@@ -4,7 +4,6 @@ import React from "react";
import { import {
Button, Button,
InputGroup,
Menu, Menu,
MenuItem, MenuItem,
Popover, Popover,
@@ -16,8 +15,11 @@ import {
import Occupancy from "./occupancy"; import Occupancy from "./occupancy";
import * as globals from "../../globals"; import * as globals from "../../globals";
import styles from "./categorical.css"; import styles from "./categorical.css";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { AnnotationsHelpers } from "../../util/stateManager"; import { AnnotationsHelpers } from "../../util/stateManager";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({ @connect(state => ({
categoricalSelection: state.categoricalSelection, categoricalSelection: state.categoricalSelection,
@@ -27,7 +29,10 @@ import { AnnotationsHelpers } from "../../util/stateManager";
pointDilation: state.pointDilation, pointDilation: state.pointDilation,
schema: state.world?.schema, schema: state.world?.schema,
world: state.world, world: state.world,
crossfilter: state.crossfilter crossfilter: state.crossfilter,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
})) }))
class CategoryValue extends React.Component { class CategoryValue extends React.Component {
constructor(props) { constructor(props) {
@@ -41,13 +46,20 @@ class CategoryValue extends React.Component {
}; };
} }
getLabel = () => { componentDidUpdate(prevProps) {
const { metadataField, categoryIndex, categoricalSelection } = this.props; const { categoricalSelection, metadataField, categoryIndex } = this.props;
const category = categoricalSelection[metadataField]; if (
const label = category.categoryValues[categoryIndex]; prevProps.categoricalSelection !== categoricalSelection ||
prevProps.metadataField !== metadataField ||
return label; prevProps.categoryIndex !== categoryIndex
}; ) {
this.setState({
editedLabelText: String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf()
});
}
}
handleDeleteValue = () => { handleDeleteValue = () => {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
@@ -84,60 +96,49 @@ class CategoryValue extends React.Component {
}); });
}; };
valueNameErrorMessage = () => { handleCreateArbitraryLabel = editedLabelTextNotInOntology => {
const err = this.valueNameError(); const { dispatch, metadataField, categoryIndex } = this.props;
if (err === false) return null; const label = this.getLabel();
const errorMessageMap = { dispatch({
/* map error code to human readable error message */ type: "annotation: label edited",
"empty-string": "Blank names not allowed", metadataField,
duplicate: "Label must be unique", editedLabel: editedLabelTextNotInOntology,
"trim-spaces": "Leading and trailing spaces not allowed", categoryIndex,
"illegal-characters": "Only alphanumeric, underscore and period allowed", label
"multi-space-run": "Multiple consecutive spaces not allowed" });
};
const errorMessage = errorMessageMap[err] ?? "error";
return (
<span
style={{
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{errorMessage}
</span>
);
}; };
valueNameError = () => { labelNameError = name => {
const { editedLabelText } = this.state; const {
const { categoricalSelection, metadataField, categoryIndex } = this.props; metadataField,
ontology,
/* schema,
check label syntax categoricalSelection,
*/ categoryIndex
const err = AnnotationsHelpers.annotationNameIsErroneous(editedLabelText); } = this.props;
if (err) return err;
/*
disallow duplicates
*/
const category = categoricalSelection[metadataField]; const category = categoricalSelection[metadataField];
const displayString = String( const displayString = String(
category.categoryValues[categoryIndex] category.categoryValues[categoryIndex]
).valueOf(); ).valueOf();
if ( if (name === displayString) return false;
category.categoryValues.indexOf(editedLabelText) > -1 && return isLabelErroneous(name, metadataField, ontology, schema);
editedLabelText !== displayString };
)
return "duplicate";
/* labelNameErrorMessage = name => {
otherwise, all good! const {
*/ metadataField,
return false; ontology,
schema,
categoricalSelection,
categoryIndex
} = this.props;
const category = categoricalSelection[metadataField];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
if (name === displayString) return null;
return labelErrorMessage(name, metadataField, ontology, schema);
}; };
activateEditLabelMode = () => { activateEditLabelMode = () => {
@@ -208,21 +209,6 @@ class CategoryValue extends React.Component {
); );
}; };
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField, categoryIndex } = this.props;
if (
prevProps.categoricalSelection !== categoricalSelection ||
prevProps.metadataField !== metadataField ||
prevProps.categoryIndex !== categoryIndex
) {
this.setState({
editedLabelText: String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf()
});
}
}
toggleOn = () => { toggleOn = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({ dispatch({
@@ -250,6 +236,23 @@ class CategoryValue extends React.Component {
}); });
}; };
handleTextChange = text => {
this.setState({ editedLabelText: text });
};
handleChoice = e => {
/* Blueprint Suggest format */
this.setState({ editedLabelText: e.target });
};
getLabel = () => {
const { metadataField, categoryIndex, categoricalSelection } = this.props;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
return label;
};
isAddCurrentSelectionDisabled(category, value) { isAddCurrentSelectionDisabled(category, value) {
/* /*
disable "add current selection to label", if one of the following is true: disable "add current selection to label", if one of the following is true:
@@ -289,6 +292,7 @@ class CategoryValue extends React.Component {
schema, schema,
isUserAnno, isUserAnno,
annotations, annotations,
ontologyEnabled,
// flippedProps is potentially brittle, their docs want {...flippedProps} on our div, // flippedProps is potentially brittle, their docs want {...flippedProps} on our div,
// our lint doesn't like jsx spread, we are version pinned to prevent api change on their part // our lint doesn't like jsx spread, we are version pinned to prevent api change on their part
flippedProps, flippedProps,
@@ -339,6 +343,12 @@ class CategoryValue extends React.Component {
)}`; )}`;
} }
const editModeActive =
isUserAnno &&
annotations.labelEditable.category === metadataField &&
annotations.isEditingLabelName &&
annotations.labelEditable.label === categoryIndex;
return ( return (
<div <div
key={i} key={i}
@@ -416,66 +426,41 @@ class CategoryValue extends React.Component {
verticalAlign: "middle" verticalAlign: "middle"
}} }}
> >
{annotations.isEditingLabelName && {truncatedString || displayString}
annotations.labelEditable.category === metadataField &&
annotations.labelEditable.label === categoryIndex
? null
: truncatedString || displayString}
</span> </span>
</Tooltip> </Tooltip>
{isUserAnno && {editModeActive ? (
annotations.labelEditable.category === metadataField && <div>
annotations.isEditingLabelName && <AnnoDialog
annotations.labelEditable.label === categoryIndex ? ( isActive={editModeActive}
<form title="Edit label"
onSubmit={e => { instruction={`New label text must be unique within category ${metadataField}:`}
e.preventDefault(); cancelTooltipContent="Close this dialog without editing label text."
if (this.valueNameError()) { primaryButtonText="Change label text"
return; text={editedLabelText}
} categoryToDuplicate={null}
this.handleEditValue(); validationError={this.labelNameError(editedLabelText)}
}} errorMessage={this.labelNameErrorMessage(editedLabelText)}
> handleSubmit={this.handleEditValue}
<InputGroup handleCancel={this.cancelEdit}
style={{ position: "relative", top: -1 }} annoInput={
ref={input => { <AnnoInputs
this.editableInput = input; useSuggest={ontologyEnabled}
}} text={editedLabelText}
small handleCreateArbitraryLabel={
autoFocus this.handleCreateArbitraryLabel
intent={this.valueNameError() ? "warning" : "none"} }
onChange={e => { handleItemChange={this.handleSuggestActiveItemChange}
this.setState({ editedLabelText: e.target.value }); handleChoice={this.handleChoice}
}} handleTextChange={this.handleTextChange}
defaultValue={displayString} isTextInvalid={this.labelNameError}
rightElement={ isTextInvalidErrorMessage={this.labelNameErrorMessage}
<Button
minimal
style={{ position: "relative", top: -1 }}
disabled={this.valueNameError()}
type="button"
icon="small-tick"
data-testclass="submitEdit"
data-testid="submitEdit"
onClick={this.handleEditValue}
/> />
} }
annoSelect={null}
/> />
{this.valueNameErrorMessage()} </div>
</form>
) : null} ) : null}
{/*
CANCEL IT, WITH BUTTON, ESCAPE KEY, CLICK OUT, UNDO?
<Button
minimal
style={{ position: "relative", top: -1 }}
type="button"
icon="cross"
data-testclass="submitEdit"
data-testid="submitEdit"
onClick={this.cancelEdit}
/> */}
</div> </div>
<span style={{ flexShrink: 0 }}> <span style={{ flexShrink: 0 }}>
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? ( {colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
@@ -483,113 +468,115 @@ class CategoryValue extends React.Component {
) : null} ) : null}
</span> </span>
</div> </div>
<span> <div>
<span <span>
data-testclass="categorical-value-count"
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{count}
</span>
<svg
display={isColorBy && categories ? "auto" : "none"}
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
}}
/>
{isUserAnno ? (
<span <span
onMouseEnter={this.handleMouseExit} data-testclass="categorical-value-count"
onMouseLeave={this.handleMouseEnter} data-testid={`categorical-value-count-${metadataField}-${displayString}`}
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
> >
<Popover {count}
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
onClick={this.handleAddCurrentSelectionToThisLabel}
text={
<span>
Re-label currently selected cells as
<span
style={{
fontStyle:
displayString ===
globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{` ${displayString}`}
</span>
</span>
}
disabled={this.isAddCurrentSelectionDisabled(
metadataField,
value
)}
/>
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="edit"
text="Edit this label's name"
data-testclass="handleEditValue"
data-testid={`handleEditValue-${metadataField}`}
onClick={this.activateEditLabelMode}
disabled={annotations.isEditingLabelName}
/>
) : null}
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteValue"
data-testid={`handleDeleteValue-${metadataField}`}
onClick={this.handleDeleteValue}
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
/>
) : null}
</Menu>
}
>
<Button
style={{
marginLeft: 0,
position: "relative",
top: -1,
minHeight: 16
}}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
small
minimal
/>
</Popover>
</span> </span>
) : null}
</span> <svg
display={isColorBy && categories ? "auto" : "none"}
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
}}
/>
{isUserAnno ? (
<span
onMouseEnter={this.handleMouseExit}
onMouseLeave={this.handleMouseEnter}
>
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
onClick={this.handleAddCurrentSelectionToThisLabel}
text={
<span>
Re-label currently selected cells as
<span
style={{
fontStyle:
displayString ===
globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{` ${displayString}`}
</span>
</span>
}
disabled={this.isAddCurrentSelectionDisabled(
metadataField,
value
)}
/>
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="edit"
text="Edit this label's name"
data-testclass="handleEditValue"
data-testid={`handleEditValue-${metadataField}`}
onClick={this.activateEditLabelMode}
disabled={annotations.isEditingLabelName}
/>
) : null}
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteValue"
data-testid={`handleDeleteValue-${metadataField}`}
onClick={this.handleDeleteValue}
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
/>
) : null}
</Menu>
}
>
<Button
style={{
marginLeft: 0,
position: "relative",
top: -1,
minHeight: 16
}}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
small
minimal
/>
</Popover>
</span>
) : null}
</span>
</div>
</div> </div>
); );
} }
-1
View File
@@ -31,7 +31,6 @@ const Annotations = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "configuration load complete": { case "configuration load complete": {
const DefaultDataCollectionName = null;
const dataCollectionName = const dataCollectionName =
action.config.parameters?.["annotations-data-collection-name"] ?? null; action.config.parameters?.["annotations-data-collection-name"] ?? null;
const dataCollectionNameIsReadOnly = const dataCollectionNameIsReadOnly =
+7
View File
@@ -165,9 +165,16 @@ const CrossfilterReducerBase = (
return state.delDimension(obsAnnoDimensionName(action.metadataField)); return state.delDimension(obsAnnoDimensionName(action.metadataField));
} }
case "annotation: add new label to category":
case "annotation: label current cell selection": case "annotation: label current cell selection":
case "annotation: label edited": case "annotation: label edited":
case "annotation: delete label": { 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 */ /* we need to reindex the dimension. For now, just drop it and add another */
const name = action.metadataField; const name = action.metadataField;
const dimName = obsAnnoDimensionName(name); const dimName = obsAnnoDimensionName(name);
+5 -3
View File
@@ -18,6 +18,7 @@ import controls from "./controls";
import resetCache from "./resetCache"; import resetCache from "./resetCache";
import annotations from "./annotations"; import annotations from "./annotations";
import autosave from "./autosave"; import autosave from "./autosave";
import ontology from "./ontology";
import centroidLabels from "./centroidLabels"; import centroidLabels from "./centroidLabels";
import pointDialation from "./pointDilation"; import pointDialation from "./pointDilation";
@@ -28,6 +29,7 @@ const Reducer = undoable(
["config", config], ["config", config],
["universe", universe], ["universe", universe],
["world", world], ["world", world],
["ontology", ontology],
["annotations", annotations], ["annotations", annotations],
["layoutChoice", layoutChoice], ["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection], ["categoricalSelection", categoricalSelection],
@@ -45,15 +47,15 @@ const Reducer = undoable(
]), ]),
[ [
"universe", "universe",
"categoricalSelection",
"world", "world",
"categoricalSelection",
"continuousSelection", "continuousSelection",
"graphSelection", "graphSelection",
"crossfilter", "crossfilter",
"layoutChoice", "colors",
"controls", "controls",
"differential", "differential",
"colors", "layoutChoice",
"centroidLabels", "centroidLabels",
"annotations" "annotations"
], ],
+29
View File
@@ -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;
+55 -18
View File
@@ -136,13 +136,27 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
"user annotations require a non-zero length string name" "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( const schema = AnnotationsHelpers.addObsAnnoCategory(
state.schema, state.schema,
annotationName, annotationName,
newLabelName 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": { case "annotation: label edited": {
@@ -208,23 +222,11 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
case "annotation: label current cell selection": { case "annotation: label current cell selection": {
const { metadataField, label } = action; const { metadataField, label } = action;
const { world, crossfilter } = prevSharedState; const { world, crossfilter } = prevSharedState;
const obsAnnotations = setLabelOnCurrentSelection(
/* state,
selection state is relative to world. We need to convert it world,
to a mask for Universe before applying it. crossfilter,
*/
const worldMask = crossfilter.allSelectedMask();
const mask = World.worldEqUniverse(world, state)
? worldMask
: AnnotationsHelpers.worldToUniverseMask(
worldMask,
world.obsAnnotations,
state.nObs
);
const obsAnnotations = AnnotationsHelpers.setLabelByMask(
state.obsAnnotations,
metadataField, metadataField,
mask,
label label
); );
return { ...state, obsAnnotations }; 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; export default Universe;
+41 -14
View File
@@ -196,6 +196,21 @@ const WorldReducer = (
case "annotation: add new label to category": { 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 */ /* add a new label to the schema - schema updated by universe reducer, we just need to note it */
const { schema } = nextSharedState.universe; 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 }; return { ...state, schema };
} }
@@ -246,21 +261,10 @@ const WorldReducer = (
case "annotation: label current cell selection": { case "annotation: label current cell selection": {
const { metadataField, label } = action; const { metadataField, label } = action;
const { crossfilter } = prevSharedState; const { crossfilter } = prevSharedState;
const mask = crossfilter.allSelectedMask(); return {
const unclipped = { ...state,
...state.unclipped, ...setLabelOnCurrentSelection(state, crossfilter, metadataField, label)
obsAnnotations: AnnotationsHelpers.setLabelByMask(
state.unclipped.obsAnnotations,
metadataField,
mask,
label
)
}; };
const obsAnnotations = state.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { ...state, obsAnnotations, unclipped };
} }
default: { 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; export default WorldReducer;
@@ -183,7 +183,7 @@ export function createWritableAnnotationDimensions(world, crossfilter) {
return crossfilter; return crossfilter;
} }
const legalCharacters = /^(\w|[ .])+$/; const legalCharacters = /^(\w|[ .()-])+$/;
export function annotationNameIsErroneous(name) { export function annotationNameIsErroneous(name) {
/* /*
Validate the name - return: Validate the name - return:
@@ -193,10 +193,9 @@ export function annotationNameIsErroneous(name) {
Tests: Tests:
0. must be string, non-null 0. must be string, non-null
1. no leading or trailing spaces 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 3. no runs of multiple spaces
*/ */
if (name === "") { if (name === "") {
return "empty-string"; return "empty-string";
} }
@@ -60,6 +60,9 @@ class ScanpyEngine(CXGDriver):
"annotations": False, "annotations": False,
"annotations_file": None, "annotations_file": None,
"annotations_output_dir": None, "annotations_output_dir": None,
"annotations_cell_ontology_enabled": False,
"annotations_cell_ontology_obopath": None,
"annotations_cell_ontology_terms": None,
"backed": False, "backed": False,
"disable_diffexp": False, "disable_diffexp": False,
"diffexp_may_be_slow": False, "diffexp_may_be_slow": False,
@@ -71,6 +74,8 @@ class ScanpyEngine(CXGDriver):
"disable-diffexp": self.config["disable_diffexp"], "disable-diffexp": self.config["disable_diffexp"],
"diffexp-may-be-slow": self.config["diffexp_may_be_slow"], "diffexp-may-be-slow": self.config["diffexp_may_be_slow"],
"annotations": self.config["annotations"], "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 self.config["annotations"]:
if uid is not None: if uid is not None:
+37
View File
@@ -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
+38
View File
@@ -15,6 +15,7 @@ from server.app.util.errors import ScanpyFileError
from server.app.util.utils import custom_format_warning from server.app.util.utils import custom_format_warning
from server.utils.utils import find_available_port, is_port_available, sort_options 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.data_locator import DataLocator
from server.app.util.ontology import load_obo, OntologyLoadFailure
# anything bigger than this will generate a special message # anything bigger than this will generate a special message
BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB 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. " help="Directory of where to save output annotations; filename will be specified in the application. "
"Incompatible with --annotations-input-file.", "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="<path or url>",
help="Location of OBO file defining cell annotatoin autosuggest terms.",)
@click.option( @click.option(
"--backed", "--backed",
"-b", "-b",
@@ -127,9 +140,15 @@ def parse_engine_args(
experimental_annotations_output_dir, experimental_annotations_output_dir,
backed, backed,
disable_diffexp, disable_diffexp,
experimental_annotations_ontology,
experimental_annotations_ontology_obo
): ):
annotations_file = experimental_annotations_file if experimental_annotations else None annotations_file = experimental_annotations_file if experimental_annotations else None
annotations_output_dir = experimental_annotations_output_dir if experimental_annotations else None annotations_output_dir = experimental_annotations_output_dir if experimental_annotations else None
annotations_cell_ontology_enabled = experimental_annotations and (
experimental_annotations_ontology or bool(experimental_annotations_ontology_obo)
)
annotations_ontology_obopath = experimental_annotations_ontology_obo if annotations_cell_ontology_enabled else None
return { return {
"layout": embedding, "layout": embedding,
"max_category_items": max_category_items, "max_category_items": max_category_items,
@@ -139,6 +158,9 @@ def parse_engine_args(
"annotations": experimental_annotations, "annotations": experimental_annotations,
"annotations_file": annotations_file, "annotations_file": annotations_file,
"annotations_output_dir": annotations_output_dir, "annotations_output_dir": annotations_output_dir,
"annotations_cell_ontology_enabled": annotations_cell_ontology_enabled,
"annotations_cell_ontology_obopath": annotations_ontology_obopath,
"annotations_cell_ontology_terms": None,
"backed": backed, "backed": backed,
"disable_diffexp": disable_diffexp, "disable_diffexp": disable_diffexp,
} }
@@ -222,6 +244,8 @@ def launch(
experimental_annotations_output_dir, experimental_annotations_output_dir,
backed, backed,
disable_diffexp, disable_diffexp,
experimental_annotations_ontology,
experimental_annotations_ontology_obo
): ):
"""Launch the cellxgene data viewer. """Launch the cellxgene data viewer.
This web app lets you explore single-cell expression data. This web app lets you explore single-cell expression data.
@@ -248,6 +272,8 @@ def launch(
experimental_annotations_output_dir, experimental_annotations_output_dir,
backed, backed,
disable_diffexp, disable_diffexp,
experimental_annotations_ontology,
experimental_annotations_ontology_obo,
) )
try: try:
data_locator = DataLocator(data) data_locator = DataLocator(data)
@@ -314,6 +340,10 @@ def launch(
click.echo("Warning: --experimental-annotations-file ignored as --annotations not enabled.") click.echo("Warning: --experimental-annotations-file ignored as --annotations not enabled.")
if experimental_annotations_output_dir is not None: if experimental_annotations_output_dir is not None:
click.echo("Warning: --experimental-annotations-output-dir ignored as --annotations not enabled.") click.echo("Warning: --experimental-annotations-output-dir ignored as --annotations not enabled.")
if experimental_annotations_ontology:
click.echo("Warning: --experimental-annotations-ontology ignored as --annotations not enabled.")
if experimental_annotations_ontology_obo is not None:
click.echo("Warning: --experimental-annotations-ontology-obo ignored as --annotations not enabled.")
else: else:
if experimental_annotations_file is not None and experimental_annotations_output_dir is not None: if experimental_annotations_file is not None and experimental_annotations_output_dir is not None:
raise click.ClickException( raise click.ClickException(
@@ -333,6 +363,14 @@ def launch(
"Unable to create directory specified by " "--experimental-annotations-output-dir" "Unable to create directory specified by " "--experimental-annotations-output-dir"
) )
if e_args.get('annotations_cell_ontology_enabled', False):
try:
e_args['annotations_cell_ontology_terms'] = load_obo(
e_args.get('annotations_cell_ontology_obopath', None)
)
except OntologyLoadFailure as e:
raise click.ClickException("Unable to load ontology terms\n" + str(e))
if about: if about:
def url_check(url): def url_check(url):
+1
View File
@@ -1,6 +1,7 @@
# TEMP anndata 0.7 breaks cellxgene - workaround # TEMP anndata 0.7 breaks cellxgene - workaround
anndata==0.6.22post1 anndata==0.6.22post1
click>=6.7 click>=6.7
fastobo>=0.6.1
Flask>=1.0.2 Flask>=1.0.2
Flask-Caching>=1.4.0 Flask-Caching>=1.4.0
Flask-Compress>=1.4.0 Flask-Compress>=1.4.0
@@ -31,6 +31,9 @@ class DataLoadEngineTest(unittest.TestCase):
"backed": False, "backed": False,
"diffexp_may_be_slow": False, "diffexp_may_be_slow": False,
"disable_diffexp": False, "disable_diffexp": False,
"annotations_cell_ontology_enabled": False,
"annotations_cell_ontology_obopath": None,
"annotations_cell_ontology_terms": None,
} }
self.data.update(args=args) self.data.update(args=args)
self.assertEqual(args, self.data.config) self.assertEqual(args, self.data.config)