mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-06 23:48:11 +08:00
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:
co-authored by
Bruce Martin
Sidney Bell
parent
8d725b1ad9
commit
d48647a655
+2
-1
@@ -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;
|
||||||
@@ -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,
|
||||||
@@ -71,7 +64,7 @@ class Categories extends React.Component {
|
|||||||
/* allow empty string */
|
/* allow empty string */
|
||||||
if (name === "") return false;
|
if (name === "") return false;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
test for uniqueness against *all* annotation names, not just the subset
|
test for uniqueness against *all* annotation names, not just the subset
|
||||||
we render as categorical.
|
we render as categorical.
|
||||||
*/
|
*/
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user