Redux refactor (#1571)

* refactor categorical controls state

* lint

* fix race condition in tests

* fix typo

* add missing update on subset

* remove obsolete code

* update jest and puppeteer major version; update all minors

* update when label changes

* remove lint from tests; increase timeouts in e2e tests

* initial refactoring to new async annomatrix

* refine error handling

* fix bad merge

* add continuous legend

* lint

* fix memoization in color table creators

* partial implementation of user defined annotations

* add new annotations action creator file

* first pass at user annotations

* additional user annotation bug fixes

* user annotation auto-save

* unit test cleanup

* lint

* refactor into multiple files

* cleanup

* add column GC

* fix several bugs in user annotations

* remove debug code

* no anonymous functions

* undo redo cleanup

* file cleanup

* scatterplot

* performance

* cleanup

* remove old code

* render in parallel with load

* fix race condition

* simply graph rendering

* render throttle DRY

* fix category label order

* fix typo in e2e test setup

* re-fix the e2e test setup

* be more tolerant of races

* anno matrix unit tests

* temp disable reembedding

* pilot port continuous histo to react-async

* name change

* lint

* fix repaint bug

* typo fix

* update snap to match new ids

* world/universe name cleanup

* move annoMatrix to src dir

* use private underscore naming convention

* fix corner case in all selected

* name cleanup

* add layout control

* init edge case

* lint

* port scatterplot

* fix label indexing bug and improve tests

* port category to react-async

* fix user annotation labelling while subset

* select all of prev layout on layout switch

* fix race with crossfilter update

* prettier lint

* fix misleading comment

* fix url composition in loader

* first pass at crossfilter tests

* lint

* lint

* fix typo

* improved error handling for network errors

* fix memoization bug

* add memo

* refactor for performnce

* add missing single-value handling in select exact parser

* small bugs discovered by tests

* lint

* additional crossfilter unit tests

* remove extraneous comment

* add support for automatic category determination

* lint

* fix render bug in category

* take advantage of schema categories guarantee

* lint

* do not clear history when resetting

* enhanced annomatrix gc

* lint

* finish renaming to follow conventions; fix clone race bug

* lint

* add priority based loading to improve initial data load UX

* crossfilter cache perf

* perf tuning

* remove timers

* documentation

* PR review changes

* PR review changes

* more PR review edits

* improve clarity of comment

* more PR review fixes

* port centroidLabels to use react-async

* remove dead code

* pr review updates

* oops, remove logging
This commit is contained in:
Bruce Martin
2020-07-14 13:53:33 -07:00
committed by GitHub
parent f69d141336
commit 1269e188be
95 changed files with 18051 additions and 5382 deletions
@@ -3,12 +3,13 @@ import { connect } from "react-redux";
import AnnoDialog from "../annoDialog";
import LabelInput from "../labelInput";
import { labelPrompt, isLabelErroneous } from "../labelUtil";
import actions from "../../../actions";
@connect((state) => ({
annotations: state.annotations,
universe: state.universe,
schema: state.annoMatrix?.schema,
ontology: state.ontology,
crossfilter: state.crossfilter,
obsCrossfilter: state.obsCrossfilter,
}))
class Category extends React.PureComponent {
constructor(props) {
@@ -34,12 +35,13 @@ class Category extends React.PureComponent {
const { newLabelText } = this.state;
this.disableAddNewLabelMode();
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: false,
});
dispatch(
actions.annotationCreateLabelInCategory(
metadataField,
newLabelText,
false
)
);
e.preventDefault();
};
@@ -48,18 +50,15 @@ class Category extends React.PureComponent {
const { newLabelText } = this.state;
this.disableAddNewLabelMode();
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: true,
});
dispatch(
actions.annotationCreateLabelInCategory(metadataField, newLabelText, true)
);
e.preventDefault();
};
labelNameError = (name) => {
const { metadataField, ontology, universe } = this.props;
return isLabelErroneous(name, metadataField, ontology, universe.schema);
const { metadataField, ontology, schema } = this.props;
return isLabelErroneous(name, metadataField, ontology, schema);
};
instruction = (label) => {
@@ -72,7 +71,7 @@ class Category extends React.PureComponent {
render() {
const { newLabelText } = this.state;
const { metadataField, annotations, ontology, crossfilter } = this.props;
const { metadataField, annotations, ontology, obsCrossfilter } = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
return (
@@ -90,7 +89,7 @@ class Category extends React.PureComponent {
instruction={this.instruction(newLabelText)}
cancelTooltipContent="Close this dialog without adding a label."
primaryButtonText="Add label"
secondaryButtonText={`Add label & assign ${crossfilter.countSelected()} selected cells`}
secondaryButtonText={`Add label & assign ${obsCrossfilter.countSelected()} selected cells`}
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
text={newLabelText}
validationError={this.labelNameError(newLabelText)}
@@ -5,11 +5,11 @@ import LabelInput from "../labelInput";
import { labelPrompt } from "../labelUtil";
import { AnnotationsHelpers } from "../../../util/stateManager";
import actions from "../../../actions";
@connect((state) => ({
annotations: state.annotations,
universe: state.universe,
schema: state.world?.schema,
schema: state.annoMatrix?.schema,
ontology: state.ontology,
}))
class AnnoDialogEditCategoryName extends React.PureComponent {
@@ -42,7 +42,9 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
test for uniqueness against *all* annotation names, not just the subset
we render as categorical.
*/
const allCategoryNames = this.allCategoryNames();
const { schema } = this.props;
const allCategoryNames = schema.annotations.obs.columns.map((c) => c.name);
if (
(allCategoryNames.indexOf(newCategoryText) > -1 &&
newCategoryText !== metadataField) ||
@@ -52,12 +54,11 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
}
this.disableEditCategoryMode();
dispatch({
type: "annotation: category edited",
metadataField,
newCategoryText,
data: newCategoryText,
});
if (metadataField !== newCategoryText)
dispatch(
actions.annotationRenameCategoryAction(metadataField, newCategoryText)
);
e.preventDefault();
};
@@ -76,7 +77,9 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
test for uniqueness against *all* annotation names, not just the subset
we render as categorical.
*/
const allCategoryNames = this.allCategoryNames();
const { schema } = this.props;
const allCategoryNames = schema.annotations.obs.columns.map((c) => c.name);
const categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
const sameName = name === metadataField;
if (categoryNameAlreadyExists && !sameName) {
@@ -12,6 +12,7 @@ import {
} from "@blueprintjs/core";
import * as globals from "../../../globals";
import actions from "../../../actions";
@connect((state) => ({
annotations: state.annotations,
@@ -41,10 +42,7 @@ class AnnoMenuCategory extends React.PureComponent {
handleDeleteCategory = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: delete category",
metadataField,
});
dispatch(actions.annotationDeleteCategoryAction(metadataField));
};
render() {
@@ -4,14 +4,17 @@ import { Flipper, Flipped } from "react-flip-toolkit";
import * as globals from "../../../globals";
import Value from "../value";
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
class Category extends React.PureComponent {
renderCategoryItems(optTuples) {
const { metadataField, isUserAnno, categorySummary } = this.props;
const {
metadataField,
isUserAnno,
categoryData,
categorySummary,
colorAccessor,
colorData,
colorTable,
} = this.props;
return optTuples.map((tuple, i) => {
return (
@@ -25,7 +28,11 @@ class Category extends React.Component {
categoryIndex={tuple[1]}
i={i}
flippedProps={flippedProps}
categoryData={categoryData}
categorySummary={categorySummary}
colorAccessor={colorAccessor}
colorData={colorData}
colorTable={colorTable}
/>
)}
</Flipped>
@@ -1,88 +1,101 @@
import React from "react";
import { connect } from "react-redux";
import React, { useRef, useEffect } from "react";
import { connect, shallowEqual } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { AnchorButton, Button, Tooltip } from "@blueprintjs/core";
import Async from "react-async";
import memoize from "memoize-one";
import CategoryFlipperLayout from "./categoryFlipperLayout";
import AnnoMenu from "./annoMenuCategory";
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
import AnnoDialogAddLabel from "./annoDialogAddLabel";
import Truncate from "../../util/truncate";
import { CategoryCrossfilterContext } from "../categoryContext";
import * as globals from "../../../globals";
import { createCategorySummary as _createCategorySummary } from "../../../util/stateManager/controlsHelpers";
import { createCategorySummaryFromDfCol } from "../../../util/stateManager/controlsHelpers";
import {
createColorTable,
createColorQuery,
} from "../../../util/stateManager/colorHelpers";
import actions from "../../../actions";
const LABEL_WIDTH = globals.leftSidebarWidth - 100;
const ANNO_BUTTON_WIDTH = 50;
const LABEL_WIDTH_ANNO = LABEL_WIDTH - ANNO_BUTTON_WIDTH;
@connect((state, ownProps) => {
const schema = state.annoMatrix?.schema;
const { metadataField } = ownProps;
const isUserAnno = schema?.annotations?.obsByName[metadataField]?.writable;
const categoricalSelection = state.categoricalSelection?.[metadataField];
return {
isColorAccessor: state.colors.colorAccessor === metadataField,
categoricalSelection: state.categoricalSelection,
colors: state.colors,
categoricalSelection,
annotations: state.annotations,
universe: state.universe,
world: state.world,
schema: state.world?.schema,
annoMatrix: state.annoMatrix,
schema,
crossfilter: state.obsCrossfilter,
isUserAnno,
};
})
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
isChecked: true,
categorySummary: this.createCategorySummary(),
};
class Category extends React.PureComponent {
static getSelectionState(
categoricalSelection,
metadataField,
categorySummary
) {
// total number of categories in this dimension
const totalCatCount = categorySummary.numCategoryValues;
// number of selected options in this category
const selectedCatCount = categorySummary.categoryValues.reduce(
(res, label) => (categoricalSelection.get(label) ?? true ? res + 1 : res),
0
);
return selectedCatCount === totalCatCount
? "all"
: selectedCatCount === 0
? "none"
: "some";
}
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField, world } = this.props;
let { categorySummary } = this.state;
if (
world !== prevProps.world ||
metadataField !== prevProps.metadataField ||
!categorySummary
) {
const newCategorySummary = this.createCategorySummary();
if (categorySummary !== newCategorySummary) {
categorySummary = newCategorySummary;
/* eslint-disable-next-line react/no-did-update-set-state -- Contained in if statement to prevent infinite looping */
this.setState({ categorySummary });
}
}
const cat = categoricalSelection?.[metadataField];
if (
categoricalSelection !== prevProps.categoricalSelection &&
!!cat &&
!!this.checkbox
) {
// total number of categories in this dimension
const totalCatCount = categorySummary.numCategoryValues;
// number of selected options in this category
const selectedCatCount = categorySummary.categoryValues.reduce(
(res, label) => (cat.get(label) ?? true ? res + 1 : res),
0
);
/* eslint-disable react/no-did-update-set-state -- Contained in if statement to prevent infinite looping */
if (selectedCatCount === totalCatCount) {
/* everything is on, so not indeterminate */
this.checkbox.indeterminate = false;
this.setState({ isChecked: true });
} else if (selectedCatCount === 0) {
/* nothing is on, so no */
this.checkbox.indeterminate = false;
this.setState({ isChecked: false });
} else if (selectedCatCount < totalCatCount) {
/* to be explicit... */
this.checkbox.indeterminate = true;
this.setState({ isChecked: false });
}
/* eslint-enable react/no-did-update-set-state -- re-enabling*/
}
static watchAsync(props, prevProps) {
return !shallowEqual(props.watchProps, prevProps.watchProps);
}
static async fetchData(annoMatrix, metadataField, colors) {
/*
fetch our data and the color-by data if appropriate, and then build a summary
of our category and a color table for the color-by annotation.
*/
const { schema } = annoMatrix;
const { colorAccessor, colorMode } = colors;
let colorDataPromise = Promise.resolve(null);
if (colorAccessor) {
const query = createColorQuery(colorMode, colorAccessor, schema);
if (query) colorDataPromise = annoMatrix.fetch(...query);
}
const [categoryData, colorData] = await Promise.all([
annoMatrix.fetch("obs", metadataField),
colorDataPromise,
]);
// our data
const column = categoryData.icol(0);
const colSchema = schema.annotations.obsByName[metadataField];
const categorySummary = createCategorySummaryFromDfCol(column, colSchema);
return [categoryData, categorySummary, colorData];
}
getSelectionState = memoize((categorySummary) => {
const { categoricalSelection, metadataField } = this.props;
return Category.getSelectionState(
categoricalSelection,
metadataField,
categorySummary
);
});
handleColorChange = () => {
const { dispatch, metadataField } = this.props;
dispatch({
@@ -101,138 +114,252 @@ class Category extends React.Component {
}
};
createCategorySummary() {
const { world, metadataField } = this.props;
if (!world || !metadataField || !world.obsAnnotations.hasCol(metadataField))
return null;
return _createCategorySummary(world, metadataField);
}
toggleNone() {
const { dispatch, metadataField } = this.props;
const { categorySummary } = this.state;
dispatch({
type: "categorical metadata filter none of these",
metadataField,
labels: categorySummary.categoryValues,
});
this.setState({ isChecked: false });
}
toggleAll() {
const { dispatch, metadataField } = this.props;
const { categorySummary } = this.state;
dispatch({
type: "categorical metadata filter all of these",
metadataField,
labels: categorySummary.categoryValues,
});
this.setState({ isChecked: true });
}
handleToggleAllClick() {
const { isChecked } = this.state;
if (isChecked) {
this.toggleNone();
} else {
this.toggleAll();
handleCategoryKeyPress = (e) => {
if (e.key === "Enter") {
this.handleCategoryClick();
}
};
handleToggleAllClick = (categorySummary) => {
const isChecked = this.getSelectionState(categorySummary);
if (isChecked === "all") {
this.toggleNone(categorySummary);
} else {
this.toggleAll(categorySummary);
}
};
fetchAsyncProps = async (props) => {
const { annoMatrix, metadataField, colors } = props.watchProps;
const { crossfilter } = this.props;
const [categoryData, categorySummary, colorData] = await Category.fetchData(
annoMatrix,
metadataField,
colors
);
return {
categoryData,
categorySummary,
colorData,
crossfilter,
...this.updateColorTable(colorData),
handleCategoryToggleAllClick: () =>
this.handleToggleAllClick(categorySummary),
};
};
updateColorTable(colorData) {
// color table, which may be null
const { schema, colors, metadataField } = this.props;
const { colorAccessor, userColors, colorMode } = colors;
return {
isColorAccessor: colorAccessor === metadataField,
colorAccessor,
colorMode,
colorTable: createColorTable(
colorMode,
colorAccessor,
colorData,
schema,
userColors
),
};
}
renderIsStillLoading() {
/*
We are still loading this category, so render a "busy" signal.
*/
const { metadataField } = this.props;
toggleNone(categorySummary) {
const { dispatch, metadataField } = this.props;
dispatch(
actions.selectCategoricalAllMetadataAction(
"categorical metadata filter none of these",
metadataField,
categorySummary.categoryValues,
false
)
);
}
toggleAll(categorySummary) {
const { dispatch, metadataField } = this.props;
dispatch(
actions.selectCategoricalAllMetadataAction(
"categorical metadata filter all of these",
metadataField,
categorySummary.categoryValues,
true
)
);
}
render() {
const {
metadataField,
isExpanded,
categoricalSelection,
crossfilter,
colors,
annoMatrix,
isUserAnno,
} = this.props;
const checkboxID = `category-select-${metadataField}`;
return (
<CategoryCrossfilterContext.Provider value={crossfilter}>
<Async
watchFn={Category.watchAsync}
promiseFn={this.fetchAsyncProps}
watchProps={{
metadataField,
annoMatrix,
categoricalSelection,
colors,
}}
>
<Async.Pending initial>
<StillLoading
metadataField={metadataField}
checkboxID={checkboxID}
/>
</Async.Pending>
<Async.Rejected>
{(error) => (
<ErrorLoading metadataField={metadataField} error={error} />
)}
</Async.Rejected>
<Async.Fulfilled>
{(asyncProps) => {
const {
colorAccessor,
colorTable,
colorData,
categoryData,
categorySummary,
isColorAccessor,
handleCategoryToggleAllClick,
} = asyncProps;
return (
<CategoryRender
metadataField={metadataField}
checkboxID={checkboxID}
isUserAnno={isUserAnno}
isTruncated={!!categorySummary?.isTruncated}
isExpanded={isExpanded}
isColorAccessor={isColorAccessor}
selectionState={this.getSelectionState(categorySummary)}
categoryData={categoryData}
categorySummary={categorySummary}
colorAccessor={colorAccessor}
colorData={colorData}
colorTable={colorTable}
onColorChangeClick={this.handleColorChange}
onCategoryToggleAllClick={handleCategoryToggleAllClick}
onCategoryMenuClick={this.handleCategoryClick}
onCategoryMenuKeyPress={this.handleCategoryKeyPress}
/>
);
}}
</Async.Fulfilled>
</Async>
</CategoryCrossfilterContext.Provider>
);
}
}
export default Category;
const StillLoading = ({ metadataField, checkboxID }) => {
/*
We are still loading this category, so render a "busy" signal.
*/
return (
<div
style={{
maxWidth: globals.maxControlsWidth,
}}
>
<div
style={{
maxWidth: globals.maxControlsWidth,
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
justifyContent: "flex-start",
alignItems: "flex-start",
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
}}
>
<label htmlFor={checkboxID} className="bp3-control bp3-checkbox">
<input disabled id={checkboxID} checked type="checkbox" />
<span className="bp3-control-indicator" />
</label>
<Truncate>
<span
style={{
cursor: "pointer",
display: "inline-block",
width: LABEL_WIDTH,
}}
>
{metadataField}
</span>
</Truncate>
</div>
<div>
<Button minimal loading intent="primary" />
</div>
</div>
</div>
);
}
render() {
const { isChecked, categorySummary } = this.state;
const { metadataField, isColorAccessor, isExpanded, schema } = this.props;
const isStillLoading = !categorySummary;
if (isStillLoading) {
return this.renderIsStillLoading();
}
const checkboxID = `category-select-${metadataField}`;
const isUserAnno = !!schema?.annotations?.obsByName[metadataField]
?.writable;
const isTruncated = !!categorySummary?.isTruncated;
if (
!isUserAnno &&
schema?.annotations?.obsByName[metadataField]?.categories?.length === 1
) {
return (
<div style={{ marginBottom: 10, marginTop: 4 }}>
<label htmlFor={checkboxID} className="bp3-control bp3-checkbox">
<input disabled id={checkboxID} checked type="checkbox" />
<span className="bp3-control-indicator" />
</label>
<Truncate>
<span style={{ maxWidth: 150, fontWeight: 700 }}>
<span
style={{
cursor: "pointer",
display: "inline-block",
width: LABEL_WIDTH,
}}
>
{metadataField}
</span>
</Truncate>
<Truncate>
<span style={{ maxWidth: 150 }}>
{`: ${schema.annotations.obsByName[metadataField].categories[0]}`}
</span>
</Truncate>
</div>
);
}
<div>
<Button minimal loading intent="primary" />
</div>
</div>
</div>
);
};
const ErrorLoading = ({ metadataField, error }) => {
console.error(error); // log error to console as it is unexpected.
return (
<div style={{ marginBottom: 10, marginTop: 4 }}>
<span
style={{
cursor: "pointer",
display: "inline-block",
width: LABEL_WIDTH,
fontStyle: "italic",
}}
>
{`Failure loading ${metadataField}`}
</span>
</div>
);
};
const CategoryHeader = React.memo(
({
metadataField,
checkboxID,
isUserAnno,
isTruncated,
isColorAccessor,
isExpanded,
selectionState,
onColorChangeClick,
onCategoryMenuClick,
onCategoryMenuKeyPress,
onCategoryToggleAllClick,
}) => {
/*
Render category name and controls (eg, color-by button).
*/
const checkboxRef = useRef(null);
useEffect(() => {
checkboxRef.current.indeterminate = selectionState === "some";
}, [checkboxRef.current, selectionState]);
return (
<CategoryFlipperLayout
metadataField={metadataField}
isExpanded={isExpanded}
isUserAnno={isUserAnno}
categorySummary={categorySummary}
>
<>
<div
style={{
display: "flex",
@@ -245,12 +372,9 @@ class Category extends React.Component {
id={checkboxID}
data-testclass="category-select"
data-testid={`${metadataField}:category-select`}
onChange={this.handleToggleAllClick.bind(this)}
ref={(el) => {
this.checkbox = el;
return el;
}}
checked={isChecked}
onChange={onCategoryToggleAllClick}
ref={checkboxRef}
checked={selectionState === "all"}
type="checkbox"
/>
<span className="bp3-control-indicator" />
@@ -260,15 +384,11 @@ class Category extends React.Component {
tabIndex="0"
data-testclass="category-expand"
data-testid={`${metadataField}:category-expand`}
onKeyPress={(e) => {
if (e.key === "Enter") {
this.handleCategoryClick();
}
}}
onKeyPress={onCategoryMenuKeyPress}
style={{
cursor: "pointer",
}}
onClick={this.handleCategoryClick}
onClick={onCategoryMenuClick}
>
<Truncate>
<span
@@ -317,7 +437,7 @@ class Category extends React.Component {
<AnchorButton
data-testclass="colorby"
data-testid={`colorby-${metadataField}`}
onClick={this.handleColorChange}
onClick={onColorChangeClick}
active={isColorAccessor}
intent={isColorAccessor ? "primary" : "none"}
disabled={isTruncated}
@@ -325,9 +445,83 @@ class Category extends React.Component {
/>
</Tooltip>
</div>
</>
);
}
);
const CategoryRender = React.memo(
({
metadataField,
checkboxID,
isUserAnno,
isTruncated,
isColorAccessor,
isExpanded,
selectionState,
categoryData,
categorySummary,
colorAccessor,
colorData,
colorTable,
onColorChangeClick,
onCategoryMenuClick,
onCategoryMenuKeyPress,
onCategoryToggleAllClick,
}) => {
/*
Render the core of the category, including checkboxes, controls, etc.
*/
const { numCategoryValues } = categorySummary;
const isSingularValue = !isUserAnno && numCategoryValues === 1;
if (isSingularValue) {
/*
Entire category has a single value, special case.
*/
const theOneValue = categorySummary.categoryValues[0];
return (
<div style={{ marginBottom: 10, marginTop: 4 }}>
<Truncate>
<span style={{ maxWidth: 150, fontWeight: 700 }}>
{metadataField}
</span>
</Truncate>
<Truncate>
<span style={{ maxWidth: 150 }}>{`: ${theOneValue}`}</span>
</Truncate>
</div>
);
}
/*
Otherwise, our normal multi-layout layout
*/
return (
<CategoryFlipperLayout
metadataField={metadataField}
isExpanded={isExpanded}
isUserAnno={isUserAnno}
categoryData={categoryData}
categorySummary={categorySummary}
colorAccessor={colorAccessor}
colorData={colorData}
colorTable={colorTable}
>
<CategoryHeader
metadataField={metadataField}
checkboxID={checkboxID}
isUserAnno={isUserAnno}
isTruncated={isTruncated}
isExpanded={isExpanded}
isColorAccessor={isColorAccessor}
selectionState={selectionState}
onColorChangeClick={onColorChangeClick}
onCategoryToggleAllClick={onCategoryToggleAllClick}
onCategoryMenuClick={onCategoryMenuClick}
onCategoryMenuKeyPress={onCategoryMenuKeyPress}
/>
</CategoryFlipperLayout>
);
}
}
export default Category;
);
@@ -0,0 +1,7 @@
import React from "react";
/*
CategoryCrossfilterContext is used to pass a snapshot of the crossfilter
matching the current category summary.
*/
export const CategoryCrossfilterContext = React.createContext(null);
+8 -6
View File
@@ -9,10 +9,11 @@ import AnnoDialog from "./annoDialog";
import AnnoSelect from "./annoSelect";
import LabelInput from "./labelInput";
import { labelPrompt } from "./labelUtil";
import actions from "../../actions";
@connect((state) => ({
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
schema: state.world?.schema,
schema: state.annoMatrix?.schema,
ontology: state.ontology,
}))
class Categories extends React.Component {
@@ -29,11 +30,12 @@ class Categories extends React.Component {
handleCreateUserAnno = (e) => {
const { dispatch } = this.props;
const { newCategoryText, categoryToDuplicate } = this.state;
dispatch({
type: "annotation: create category",
data: newCategoryText,
categoryToDuplicate,
});
dispatch(
actions.annotationCreateCategoryAction(
newCategoryText,
categoryToDuplicate
)
);
this.setState({
createAnnoModeActive: false,
categoryToDuplicate: null,
+105 -120
View File
@@ -19,8 +19,10 @@ import Truncate from "../../util/truncate";
import { AnnotationsHelpers } from "../../../util/stateManager";
import { labelPrompt, isLabelErroneous } from "../labelUtil";
import actions from "../../../actions";
import MiniHistogram from "../../miniHistogram";
import MiniStackedBar from "../../miniStackedBar";
import { CategoryCrossfilterContext } from "../categoryContext";
const VALUE_HEIGHT = 11;
const CHART_WIDTH = 100;
@@ -42,11 +44,7 @@ function _currentLabelAsString(ownProps) {
return {
categoricalSelection,
annotations: state.annotations,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
schema: state.world?.schema,
world: state.world,
crossfilter: state.crossfilter,
schema: state.annoMatrix?.schema,
ontology: state.ontology,
isDilated,
};
@@ -95,50 +93,37 @@ class CategoryValue extends React.Component {
handleDeleteValue = () => {
const { dispatch, metadataField } = this.props;
const label = this.getLabel();
dispatch({
type: "annotation: delete label",
metadataField,
label,
});
dispatch(actions.annotationDeleteLabelFromCategory(metadataField, label));
};
handleAddCurrentSelectionToThisLabel = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const { dispatch, metadataField } = this.props;
const label = this.getLabel();
dispatch({
type: "annotation: label current cell selection",
metadataField,
categoryIndex,
label,
});
dispatch(actions.annotationLabelCurrentSelection(metadataField, label));
};
handleEditValue = (e) => {
const { dispatch, metadataField, categoryIndex } = this.props;
const { dispatch, metadataField } = this.props;
const { editedLabelText } = this.state;
const label = this.getLabel();
this.cancelEditMode();
dispatch({
type: "annotation: label edited",
editedLabel: editedLabelText,
metadataField,
categoryIndex,
label,
});
dispatch(
actions.annotationRenameLabelInCategory(
metadataField,
label,
editedLabelText
)
);
e.preventDefault();
};
handleCreateArbitraryLabel = (txt) => {
const { dispatch, metadataField, categoryIndex } = this.props;
const { dispatch, metadataField } = this.props;
const label = this.getLabel();
this.cancelEditMode();
dispatch({
type: "annotation: label edited",
metadataField,
editedLabel: txt,
categoryIndex,
label,
});
dispatch(
actions.annotationRenameLabelInCategory(metadataField, label, txt)
);
};
labelNameError = (name) => {
@@ -185,13 +170,15 @@ class CategoryValue extends React.Component {
} = this.props;
const labels = categorySummary.categoryValues;
const label = labels[categoryIndex];
dispatch({
type: "categorical metadata filter deselect",
metadataField,
categoryIndex,
label,
labels,
});
dispatch(
actions.selectCategoricalMetadataAction(
"categorical metadata filter deselect",
metadataField,
labels,
label,
false
)
);
};
shouldComponentUpdate = (nextProps, nextState) => {
@@ -224,11 +211,8 @@ class CategoryValue extends React.Component {
categoricalSelection[metadataField].get(label) !==
newCategoricalSelection[metadataField].get(newLabel);
const worldChange = props.world !== nextProps.world;
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
const annotationsChange = props.annotations !== nextProps.annotations;
const crossfilterChange =
props.isUserAnno && props.crossfilter !== nextProps.crossfilter;
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
const dilationChange = props.isDilated !== nextProps.isDilated;
@@ -239,10 +223,8 @@ class CategoryValue extends React.Component {
return (
labelChanged ||
valueSelectionChange ||
worldChange ||
colorAccessorChange ||
annotationsChange ||
crossfilterChange ||
editingLabel ||
dilationChange ||
countChanged
@@ -258,13 +240,15 @@ class CategoryValue extends React.Component {
} = this.props;
const labels = categorySummary.categoryValues;
const label = labels[categoryIndex];
dispatch({
type: "categorical metadata filter select",
metadataField,
categoryIndex,
label,
labels,
});
dispatch(
actions.selectCategoricalMetadataAction(
"categorical metadata filter select",
metadataField,
labels,
label,
true
)
);
};
handleMouseEnter = () => {
@@ -299,10 +283,11 @@ class CategoryValue extends React.Component {
};
createHistogramBins = (
world,
metadataField,
categoryData,
colorAccessor,
value,
colorData,
categoryValue,
width,
height
) => {
@@ -311,12 +296,8 @@ class CategoryValue extends React.Component {
createHistogramBins fetches the continuous data in relation to the cells relevant to the category value.
It then separates that data into 50 bins for drawing the mini-histogram
*/
const groupBy = world.obsAnnotations.col(metadataField);
const col =
world.obsAnnotations.col(colorAccessor) ||
world.varData.col(colorAccessor);
const groupBy = categoryData.col(metadataField);
const col = colorData.icol(0);
const range = col.summarize();
const histogramMap = col.histogram(
@@ -325,8 +306,8 @@ class CategoryValue extends React.Component {
groupBy
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
const bins = histogramMap.has(value)
? histogramMap.get(value)
const bins = histogramMap.has(categoryValue)
? histogramMap.get(categoryValue)
: new Array(50).fill(0);
const xScale = d3.scaleLinear().domain([0, bins.length]).range([0, width]);
@@ -343,10 +324,13 @@ class CategoryValue extends React.Component {
};
createStackedGraphBins = (
world,
metadataField,
categoryData,
colorAccessor,
colorData,
categoryValue,
colorTable,
schema,
width
) => {
/*
@@ -354,10 +338,8 @@ class CategoryValue extends React.Component {
createOccupancyStack obtains a map showing the number if cells per colored value
Using the colorScale a stack of colored bars is drawn representing the map
*/
const { schema } = world;
const groupBy = world.obsAnnotations.col(metadataField);
const occupancyMap = world.obsAnnotations
const groupBy = categoryData.col(metadataField);
const occupancyMap = colorData
.col(colorAccessor)
.histogramCategorical(groupBy);
@@ -373,7 +355,7 @@ class CategoryValue extends React.Component {
const categories =
schema.annotations.obsByName[colorAccessor]?.categories;
const dfColumn = world.obsAnnotations.col(colorAccessor);
const dfColumn = colorData.col(colorAccessor);
const categoryValues = dfColumn.summarizeCategorical().categories;
return {
@@ -390,13 +372,13 @@ class CategoryValue extends React.Component {
return _currentLabelAsString(this.props);
}
isAddCurrentSelectionDisabled(category, value) {
isAddCurrentSelectionDisabled(crossfilter, category, value) {
/*
disable "add current selection to label", if one of the following is true:
1. no cells are selected
2. all currently selected cells already have this label, on this category
*/
const { crossfilter, world } = this.props;
const { categoryData } = this.props;
// 1. no cells selected?
if (crossfilter.countSelected() === 0) {
@@ -405,12 +387,7 @@ class CategoryValue extends React.Component {
// 2. all selected cells already have the label
const mask = crossfilter.allSelectedMask();
if (
AnnotationsHelpers.allHaveLabelByMask(
world.obsAnnotations,
category,
value,
mask
)
AnnotationsHelpers.allHaveLabelByMask(categoryData, category, value, mask)
) {
return true;
}
@@ -422,11 +399,12 @@ class CategoryValue extends React.Component {
const {
categoricalSelection,
colorAccessor,
colorScale,
metadataField,
world,
categoryData,
colorData,
colorTable,
schema,
} = this.props;
const isColorBy = metadataField === colorAccessor;
if (
@@ -439,10 +417,13 @@ class CategoryValue extends React.Component {
const { domainValues, scale, domain, occupancy } =
this.createStackedGraphBins(
world,
metadataField,
categoryData,
colorAccessor,
colorData,
categoryValue,
colorTable,
schema,
CHART_WIDTH
) ?? {};
@@ -454,7 +435,7 @@ class CategoryValue extends React.Component {
<MiniStackedBar
/* eslint-disable react/jsx-props-no-spreading -- Disable unneeded on next release of eslint-config-airbnb */
{...{
colorScale,
colorTable,
domainValues,
scale,
domain,
@@ -471,10 +452,12 @@ class CategoryValue extends React.Component {
const {
categoricalSelection,
colorAccessor,
colorScale,
world,
metadataField,
colorData,
categoryData,
colorTable,
} = this.props;
const colorScale = colorTable?.scale;
if (
!this.shouldRenderStackedBarOrHistogram ||
@@ -485,9 +468,10 @@ class CategoryValue extends React.Component {
const { xScale, yScale, bins } =
this.createHistogramBins(
world,
metadataField,
categoryData,
colorAccessor,
colorData,
categoryValue,
CHART_WIDTH,
VALUE_HEIGHT
@@ -517,9 +501,8 @@ class CategoryValue extends React.Component {
metadataField,
categoryIndex,
colorAccessor,
colorScale,
colorTable,
i,
schema,
isUserAnno,
annotations,
ontology,
@@ -529,6 +512,7 @@ class CategoryValue extends React.Component {
isDilated,
categorySummary,
} = this.props;
const colorScale = colorTable?.scale;
const ontologyEnabled = ontology?.enabled ?? false;
const { editedLabelText } = this.state;
@@ -543,11 +527,7 @@ class CategoryValue extends React.Component {
/* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor;
let categories = null;
if (isColorBy && schema) {
categories = schema.annotations.obsByName[colorAccessor]?.categories;
}
const { categoryValueIndices } = categorySummary;
const editModeActive =
isUserAnno &&
@@ -555,7 +535,7 @@ class CategoryValue extends React.Component {
annotations.isEditingLabelName &&
annotations.labelEditable.label === categoryIndex;
const valueToggleLabel = `value-toggle-checkbox-${displayString}`;
const valueToggleLabel = `value-toggle-checkbox-${metadataField}-${displayString}`;
const LEFT_MARGIN = 60;
const CHECKBOX = 26;
@@ -719,14 +699,14 @@ class CategoryValue extends React.Component {
</span>
<svg
display={isColorBy && categories ? "auto" : "none"}
display={isColorBy && categoryValueIndices ? "auto" : "none"}
style={{
marginLeft: 5,
width: VALUE_HEIGHT,
height: VALUE_HEIGHT,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
isColorBy && categoryValueIndices
? colorScale(categoryValueIndices.get(value))
: "inherit",
}}
/>
@@ -741,32 +721,37 @@ class CategoryValue extends React.Component {
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`${metadataField}:${displayString}:add-current-selection-to-this-label`}
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
<CategoryCrossfilterContext.Consumer>
{(crossfilter) => (
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`${metadataField}:${displayString}:add-current-selection-to-this-label`}
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(
crossfilter,
metadataField,
value
)}
/>
)}
/>
</CategoryCrossfilterContext.Consumer>
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="edit"
@@ -9,7 +9,9 @@ import {
Classes,
} from "@blueprintjs/core";
@connect()
@connect((state) => ({
schema: state.annoMatrix?.schema,
}))
class Occupancy extends React.PureComponent {
_WIDTH = 100;
@@ -21,16 +23,17 @@ class Occupancy extends React.PureComponent {
createHistogram fetches the continous data in relation to the cells releveant to the catagory value.
It then seperates that data into 50 bins for drawing the mini-histogram
*/
const { world, metadataField, colorAccessor, categoryValue } = this.props;
const {
metadataField,
categoryData,
colorData,
categoryValue,
} = this.props;
if (!this.canvas) return;
const groupBy = world.obsAnnotations.col(metadataField);
const col =
world.obsAnnotations.col(colorAccessor) ||
world.varData.col(colorAccessor);
const groupBy = categoryData.col(metadataField);
const col = colorData.icol(0);
const range = col.summarize();
const histogramMap = col.histogram(
@@ -39,7 +42,6 @@ class Occupancy extends React.PureComponent {
groupBy
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
// const categoryValue = category.categoryValues[categoryIndex];
const bins = histogramMap.has(categoryValue)
? histogramMap.get(categoryValue)
: new Array(50).fill(0);
@@ -79,20 +81,22 @@ class Occupancy extends React.PureComponent {
Using the colorScale a stack of colored bars is drawn representing the map
*/
const {
world,
metadataField,
categoryData,
colorAccessor,
categoryValue,
colorScale,
colorTable,
schema,
colorData,
} = this.props;
const { schema } = world;
const { scale: colorScale } = colorTable;
const ctx = this.canvas?.getContext("2d");
if (!ctx) return;
const groupBy = world.obsAnnotations.col(metadataField);
const occupancyMap = world.obsAnnotations
const groupBy = categoryData.col(metadataField);
const occupancyMap = colorData
.col(colorAccessor)
.histogramCategorical(groupBy);
@@ -109,7 +113,7 @@ class Occupancy extends React.PureComponent {
schema.annotations.obsByName[colorAccessor]?.categories;
let currentOffset = 0;
const dfColumn = world.obsAnnotations.col(colorAccessor);
const dfColumn = colorData.col(colorAccessor);
const categoryValues = dfColumn.summarizeCategorical().categories;
let o;