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
+2 -2
View File
@@ -64,10 +64,10 @@ class App extends React.Component {
left: window.innerWidth / 2 - 50,
}}
>
error loading
error loading cellxgene
</div>
) : null}
{loading ? null : (
{loading || error ? null : (
<Layout>
<LeftSideBar />
{(viewportRef) => (
@@ -11,13 +11,8 @@ import {
} from "@blueprintjs/core";
@connect((state) => ({
universe: state.universe,
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
annotations: state.annotations,
obsAnnotations: state.universe.obsAnnotations,
saveInProgress: state.autosave?.saveInProgress ?? false,
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
}))
class FilenameDialog extends React.Component {
+12 -12
View File
@@ -4,14 +4,12 @@ import actions from "../../actions";
import FilenameDialog from "./filenameDialog";
@connect((state) => ({
universe: state.universe,
annotations: state.annotations,
obsAnnotations: state.universe.obsAnnotations,
saveInProgress: state.autosave?.saveInProgress ?? false,
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
initialDataLoadComplete: state.autosave?.initialDataLoadComplete,
annoMatrix: state.annoMatrix,
lastSavedAnnoMatrix: state.autosave?.lastSavedAnnoMatrix,
}))
class Autosave extends React.Component {
constructor(props) {
@@ -42,16 +40,14 @@ class Autosave extends React.Component {
tick = () => {
const { dispatch, saveInProgress } = this.props;
if (this.needToSave() && !saveInProgress) {
dispatch(actions.saveObsAnnotations());
dispatch(actions.saveObsAnnotationsAction());
}
};
needToSave = () => {
/* return true if we need to save, false if we don't */
const { obsAnnotations, lastSavedObsAnnotations } = this.props;
return (
lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations
);
const { annoMatrix, lastSavedAnnoMatrix } = this.props;
return actions.needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix);
};
statusMessage() {
@@ -66,9 +62,13 @@ class Autosave extends React.Component {
const {
writableCategoriesEnabled,
saveInProgress,
initialDataLoadComplete,
lastSavedAnnoMatrix,
} = this.props;
return writableCategoriesEnabled ? (
const initialDataLoadComplete = lastSavedAnnoMatrix;
if (!writableCategoriesEnabled) return null;
return (
<div
id="autosave"
data-testclass={
@@ -89,7 +89,7 @@ class Autosave extends React.Component {
{this.statusMessage()}
<FilenameDialog />
</div>
) : null;
);
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
+7 -76
View File
@@ -1,97 +1,28 @@
// jshint esversion: 6
/* rc slider https://www.npmjs.com/package/rc-slider */
import React from "react";
import { connect } from "react-redux";
import { Button } from "@blueprintjs/core";
import * as globals from "../../globals";
import HistogramBrush from "../brushableHistogram";
@connect((state) => ({
obsAnnotations: state.world?.obsAnnotations,
colorAccessor: state.colors.colorAccessor,
colorScale: state.colors.scale,
schema: state.world?.schema,
schema: state.annoMatrix?.schema,
}))
class Continuous extends React.PureComponent {
static renderIsStillLoading(zebra, key) {
return (
<div
key={key}
style={{
padding: globals.leftSidebarSectionPadding,
backgroundColor: zebra % 2 === 0 ? globals.lightestGrey : "white",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
justifyItems: "center",
alignItems: "center",
}}
>
<div style={{ minWidth: 30 }} />
<div style={{ display: "flex", alignSelf: "center" }}>
<span style={{ fontStyle: "italic" }}>{key}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
}}
>
<Button minimal loading intent="primary" />
</div>
</div>
</div>
);
}
render() {
const { obsAnnotations, schema } = this.props;
/* initial value for iterator to simulate index, ranges is an object */
const { schema } = this.props;
if (!schema) return null;
const obsIndex = schema.annotations.obs.index;
const allContinuousNames = schema.annotations.obs.columns
.filter((col) => col.type === "int32" || col.type === "float32")
.filter((col) => col.name !== obsIndex)
.map((col) => col.name);
/* initial value for iterator to simulate index, ranges is an object */
let zebra = 0;
return (
<div>
{allContinuousNames.map((key) => {
if (!obsAnnotations.hasCol(key)) {
// still loading!
zebra += 1;
return Continuous.renderIsStillLoading(zebra, key);
}
// data loaded and available
const summary = obsAnnotations.col(key).summarize();
const nonFiniteExtent =
summary.min === undefined ||
summary.max === undefined ||
Number.isNaN(summary.min) ||
Number.isNaN(summary.max);
if (!summary.categorical && !nonFiniteExtent) {
zebra += 1;
return (
<HistogramBrush
key={key}
field={key}
isObs
zebra={zebra % 2 === 0}
ranges={summary}
/>
);
}
return null;
})}
{allContinuousNames.map((key, zebra) => (
<HistogramBrush key={key} field={key} isObs zebra={zebra % 2 === 0} />
))}
</div>
);
}
+58 -20
View File
@@ -4,6 +4,11 @@ import { connect } from "react-redux";
import * as d3 from "d3";
import { interpolateCool } from "d3-scale-chromatic";
import {
createColorTable,
createColorQuery,
} from "../../util/stateManager/colorHelpers";
// create continuous color legend
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
const continuous = (selectorId, colorscale, colorAccessor) => {
@@ -101,38 +106,71 @@ const continuous = (selectorId, colorscale, colorAccessor) => {
};
@connect((state) => ({
colorAccessor: state.colors.colorAccessor,
colorScale: state.colors.scale,
annoMatrix: state.annoMatrix,
colors: state.colors,
}))
class ContinuousLegend extends React.Component {
constructor(props) {
super(props);
this.ref = null;
this.state = {
colorAccessor: null,
colorScale: null,
};
}
componentDidMount() {
this.updateState(null);
}
componentDidUpdate(prevProps) {
const { colorAccessor, colorScale } = this.props;
const range = colorScale?.range;
const [domainMin, domainMax] = colorScale?.domain?.() ?? [0, 0];
this.updateState(prevProps);
}
async updateState(prevProps) {
const { annoMatrix, colors } = this.props;
if (!colors || !annoMatrix) return;
if (colors !== prevProps?.colors || annoMatrix !== prevProps?.annoMatrix) {
const { schema } = annoMatrix;
const { colorMode, colorAccessor, userColors } = colors;
const colorQuery = createColorQuery(colorMode, colorAccessor, schema);
const colorDf = colorQuery ? await annoMatrix.fetch(...colorQuery) : null;
const colorTable = createColorTable(
colorMode,
colorAccessor,
colorDf,
schema,
userColors
);
const colorScale = colorTable.scale;
const range = colorScale?.range;
const [domainMin, domainMax] = colorScale?.domain?.() ?? [0, 0];
if (
prevProps.colorAccessor !== colorAccessor ||
prevProps.colorScale !== colorScale
) {
/* always remove it, if it's not continuous we don't put it back. */
d3.select("#continuous_legend").selectAll("*").remove();
}
if (colorAccessor && colorScale && range && domainMin < domainMax) {
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
if (range()[0][0] !== "#") {
continuous(
"#continuous_legend",
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
colorAccessor
);
if (colorAccessor && colorScale && range && domainMin < domainMax) {
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
if (range()[0][0] !== "#") {
continuous(
"#continuous_legend",
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
colorAccessor
);
}
}
this.setState({
colorAccessor,
colorScale: colorTable.scale,
});
}
}
render() {
const { colorAccessor, colorScale } = this.props;
const { colorAccessor, colorScale } = this.state;
if (
colorScale?.domain &&
colorScale.domain()[1] === colorScale.domain()[0]
+10 -6
View File
@@ -5,6 +5,7 @@ import { Position, Toaster, Intent } from "@blueprintjs/core";
const ToastTopCenter = Toaster.create({
className: "recipe-toaster",
position: Position.TOP,
maxToasts: 4,
});
/*
@@ -23,12 +24,15 @@ export const keepAroundErrorToast = (message) =>
/*
a hard network error
*/
export const postNetworkErrorToast = (message) =>
ToastTopCenter.show({
message,
timeout: 30000,
intent: Intent.DANGER,
});
export const postNetworkErrorToast = (message, key = undefined) =>
ToastTopCenter.show(
{
message,
timeout: 30000,
intent: Intent.DANGER,
},
key
);
/*
Async message to user
@@ -1,4 +1,3 @@
// jshint esversion: 6
/* rc slider https://www.npmjs.com/package/rc-slider */
import React from "react";
@@ -34,9 +33,6 @@ const renderGene = (fuzzySortResult, { handleClick, modifiers }) => {
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 */
@@ -56,11 +52,9 @@ const filterGenes = (query, genes) =>
@connect((state) => {
return {
obsAnnotations: state.world?.obsAnnotations,
annoMatrix: state.annoMatrix,
userDefinedGenes: state.controls.userDefinedGenes,
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
world: state.world,
colorAccessor: state.colors.colorAccessor,
differential: state.differential,
};
})
@@ -71,9 +65,19 @@ class AddGenes extends React.Component {
bulkAdd: "",
tab: "autosuggest",
activeItem: null,
geneNames: [],
status: "pending",
};
}
componentDidMount() {
this.updateState();
}
componentDidUpdate(prevProps) {
this.updateState(prevProps);
}
_genesToUpper = (listGenes) => {
// Has to be a Map to preserve index
const upperGenes = new Map();
@@ -88,9 +92,8 @@ class AddGenes extends React.Component {
_memoGenesToUpper = memoize(this._genesToUpper, (arr) => arr);
handleBulkAddClick = () => {
const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
const { bulkAdd } = this.state;
const { dispatch, userDefinedGenes } = this.props;
const { bulkAdd, geneNames } = this.state;
/*
test:
@@ -98,18 +101,14 @@ class AddGenes extends React.Component {
*/
if (bulkAdd !== "") {
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
console.log("geneExpression genes", genes);
if (genes.length === 0) {
return keepAroundErrorToast("Must enter a gene name.");
}
const worldGenes =
world.varAnnotations?.col(varIndexName)?.asArray() || [];
// These gene lists are unique enough where memoization is useless
const upperGenes = this._genesToUpper(genes);
const upperUserDefinedGenes = this._genesToUpper(userDefinedGenes);
const upperWorldGenes = this._memoGenesToUpper(worldGenes);
const upperGeneNames = this._memoGenesToUpper(geneNames);
dispatch({ type: "bulk user defined gene start" });
@@ -119,7 +118,7 @@ class AddGenes extends React.Component {
return keepAroundErrorToast("That gene already exists");
}
const indexOfGene = upperWorldGenes.get(upperGene);
const indexOfGene = upperGeneNames.get(upperGene);
if (indexOfGene === undefined) {
return keepAroundErrorToast(
@@ -129,7 +128,7 @@ class AddGenes extends React.Component {
);
}
return dispatch(
actions.requestUserDefinedGene(worldGenes[indexOfGene])
actions.requestUserDefinedGene(geneNames[indexOfGene])
);
})
).then(
@@ -142,6 +141,27 @@ class AddGenes extends React.Component {
return undefined;
};
async updateState(prevProps) {
const { annoMatrix } = this.props;
if (!annoMatrix) return;
if (annoMatrix !== prevProps?.annoMatrix) {
const { schema } = annoMatrix;
const varIndex = schema.annotations.var.index;
this.setState({ status: "pending" });
try {
const df = await annoMatrix.fetch("var", varIndex);
this.setState({
status: "success",
geneNames: df.col(varIndex).asArray(),
});
} catch (error) {
this.setState({ status: "error" });
throw error;
}
}
}
placeholderGeneNames() {
/*
return a string containing gene name suggestions for use as a user hint.
@@ -151,10 +171,7 @@ class AddGenes extends React.Component {
NOTE: the random selection means it will re-render constantly.
*/
const { world } = this.props;
const { varAnnotations } = world;
const varIndexName = world.schema.annotations.var.index;
const geneNames = varAnnotations.col(varIndexName).asArray();
const { geneNames } = this.state;
if (geneNames.length > 0) {
const placeholder = [];
let len = geneNames.length;
@@ -175,8 +192,8 @@ class AddGenes extends React.Component {
}
handleClick(g) {
const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
const { dispatch, userDefinedGenes } = this.props;
const { geneNames } = this.state;
if (!g) return;
const gene = g.target;
if (userDefinedGenes.indexOf(gene) !== -1) {
@@ -185,27 +202,21 @@ class AddGenes extends React.Component {
postUserErrorToast(
`That's too many genes, you can have at most ${globals.maxUserDefinedGenes} user defined genes`
);
} else if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
} else if (geneNames.indexOf(gene) === undefined) {
postUserErrorToast("That doesn't appear to be a valid gene name.");
} else {
dispatch({ type: "single user defined gene start" });
dispatch(actions.requestUserDefinedGene(gene)).then(
() => dispatch({ type: "single user defined gene complete" }),
() => dispatch({ type: "single user defined gene error" })
);
dispatch(actions.requestUserDefinedGene(gene));
dispatch({ type: "single user defined gene complete" });
}
}
render() {
const { world, userDefinedGenesLoading } = this.props;
const varIndexName = world?.schema?.annotations?.var?.index;
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
const { tab, bulkAdd, activeItem } = this.state;
const { userDefinedGenesLoading } = this.props;
const { tab, bulkAdd, activeItem, status, geneNames } = this.state;
// may still be loading!
if (!varIndex) return null;
if (status !== "success") return null;
return (
<div>
@@ -263,7 +274,7 @@ class AddGenes extends React.Component {
itemListPredicate={filterGenes}
onActiveItemChange={(item) => this.setState({ activeItem: item })}
itemRenderer={renderGene}
items={varIndex || ["No genes"]}
items={geneNames || ["No genes"]}
popoverProps={{ minimal: true }}
/>
<Button
+4 -28
View File
@@ -1,4 +1,3 @@
// jshint esversion: 6
/* rc slider https://www.npmjs.com/package/rc-slider */
import React from "react";
@@ -10,23 +9,13 @@ import AddGenes from "./addGenes";
@connect((state) => {
return {
obsAnnotations: state.world?.obsAnnotations,
userDefinedGenes: state.controls.userDefinedGenes,
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
world: state.world,
colorAccessor: state.colors.colorAccessor,
differential: state.differential,
};
})
class GeneExpression extends React.Component {
render() {
const { world, userDefinedGenes, differential } = this.props;
const varIndexName = world?.schema?.annotations?.var?.index;
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
// may still be loading!
if (!varIndex) return null;
const { userDefinedGenes, differential } = this.props;
return (
<div
style={{
@@ -35,19 +24,13 @@ class GeneExpression extends React.Component {
>
<div>
<AddGenes />
{world && userDefinedGenes.length > 0
{userDefinedGenes.length > 0
? _.map(userDefinedGenes, (geneName, index) => {
const values = world.varData.col(geneName);
if (!values) {
return null;
}
const summary = values.summarize();
return (
<HistogramBrush
key={geneName}
field={geneName}
zebra={index % 2 === 0}
ranges={summary}
isUserDefined
/>
);
@@ -57,18 +40,11 @@ class GeneExpression extends React.Component {
<div>
{differential.diffExp
? _.map(differential.diffExp, (value, index) => {
const name = world.varAnnotations.at(value[0], varIndexName);
const values = world.varData.col(name);
if (!values) {
return null;
}
const summary = values.summarize();
return (
<HistogramBrush
key={name}
field={name}
key={value[0]}
field={value[0]}
zebra={index % 2 === 0}
ranges={summary}
isDiffExp
logFoldChange={value[1]}
pval={value[2]}
+374 -257
View File
@@ -1,18 +1,25 @@
// jshint esversion: 6
import React from "react";
import * as d3 from "d3";
import { connect } from "react-redux";
import { connect, shallowEqual } from "react-redux";
import { mat3, vec2 } from "gl-matrix";
import _regl from "regl";
import memoize from "memoize-one";
import Async from "react-async";
import setupSVGandBrushElements from "./setupSVGandBrush";
import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl";
import { isTypedArray } from "../../util/typeHelpers";
import {
createColorTable,
createColorQuery,
} from "../../util/stateManager/colorHelpers";
import * as globals from "../../globals";
import GraphOverlayLayer from "./overlays/graphOverlayLayer";
import CentroidLabels from "./overlays/centroidLabels";
import actions from "../../actions";
import renderThrottle from "../../util/renderThrottle";
/*
Simple 2D transforms control all point painting. There are three:
@@ -53,38 +60,49 @@ function createModelTF() {
return m;
}
function renderThrottle(callback) {
/*
This wraps a call to requestAnimationFrame(), enforcing a single
render callback at any given time (ie, you can call this any number
of times, and it will coallesce multiple inter-frame calls into a
single render).
*/
let rafCurrentlyInProgress = null;
return function f() {
if (rafCurrentlyInProgress) return;
const context = this;
rafCurrentlyInProgress = window.requestAnimationFrame(() => {
callback.apply(context);
rafCurrentlyInProgress = null;
});
};
}
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
@connect((state) => ({
universe: state.universe,
world: state.world,
crossfilter: state.crossfilter,
colorRGB: state.colors.rgb,
annoMatrix: state.annoMatrix,
crossfilter: state.obsCrossfilter,
selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection,
layoutChoice: state.layoutChoice,
centroidLabels: state.centroidLabels,
graphInteractionMode: state.controls.graphInteractionMode,
colorAccessor: state.colors.colorAccessor,
colors: state.colors,
pointDilation: state.pointDilation,
}))
class Graph extends React.Component {
static createReglState(canvas) {
/*
Must be created for each canvas
*/
// setup canvas, webgl draw function and camera
const camera = _camera(canvas);
const regl = _regl(canvas);
const drawPoints = _drawPoints(regl);
// preallocate webgl buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const flagBuffer = regl.buffer();
return {
camera,
regl,
drawPoints,
pointBuffer,
colorBuffer,
flagBuffer,
};
}
static watchAsync(props, prevProps) {
return !shallowEqual(props.watchProps, prevProps.watchProps);
}
computePointPositions = memoize((X, Y, modelTF) => {
/*
compute the model coordinate for each point
@@ -111,18 +129,18 @@ class Graph extends React.Component {
});
computeSelectedFlags = memoize(
(crossfilter, flagSelected, flagUnselected) => {
(crossfilter, _flagSelected, _flagUnselected) => {
const x = crossfilter.fillByIsSelected(
new Float32Array(crossfilter.size()),
flagSelected,
flagUnselected
_flagSelected,
_flagUnselected
);
return x;
}
);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, pointDilation) => {
(crossfilter, colorByData, pointDilationData, pointDilationLabel) => {
/*
We communicate with the shader using three flags:
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
@@ -136,32 +154,17 @@ class Graph extends React.Component {
continuous metadata, as they rely on different tests, and some of the flags
(eg, isNaN) are meaningless in the face of categorical metadata.
*/
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
const flags = this.computeSelectedFlags(
crossfilter,
flagSelected,
0
).slice();
const { metadataField, categoryField } = pointDilation;
const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray()
: null;
const colorByColumn = colorAccessor
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
world.varData.col(colorAccessor)?.asArray()
: null;
const colorByData =
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
if (colorByData || highlightData) {
if (colorByData || pointDilationData) {
for (let i = 0, len = flags.length; i < len; i += 1) {
if (highlightData) {
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
if (pointDilationData) {
flags[i] +=
pointDilationData[i] === pointDilationLabel ? flagHighlight : 0;
}
if (colorByData) {
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
@@ -175,167 +178,85 @@ class Graph extends React.Component {
constructor(props) {
super(props);
const viewport = this.getViewportDimensions();
this.count = 0;
this.renderCache = {
X: null,
Y: null,
positions: null,
colors: null,
sizes: null,
flags: null,
};
this.reglCanvas = null;
this.cachedAsyncProps = null;
const modelTF = createModelTF();
this.state = {
toolSVG: null,
tool: null,
container: null,
cameraRender: 0,
viewport,
// projection
camera: null,
modelTF,
modelInvTF: mat3.invert([], modelTF),
projectionTF: null,
// regl state
regl: null,
drawPoints: null,
pointBuffer: null,
colorBuffer: null,
flagBuffer: null,
// component rendering derived state - these must stay synchronized
// with the reducer state they were generated from.
layoutState: {
layoutDf: null,
layoutChoice: null,
},
colorState: {
colors: null,
colorDf: null,
colorTable: null,
},
pointDilationState: {
pointDilation: null,
pointDilationDf: null,
},
};
}
componentDidMount() {
window.addEventListener("resize", this.handleResize);
// setup canvas, webgl draw function and camera
const camera = _camera(this.reglCanvas);
const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl);
// preallocate webgl buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const flagBuffer = regl.buffer();
// create all default rendering transformations
const modelTF = createModelTF();
const projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
// initial draw to canvas
this.renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
);
const { viewport } = this.state;
const projectionTF = createProjectionTF(viewport.width, viewport.height);
this.setState({
regl,
drawPoints,
pointBuffer,
colorBuffer,
flagBuffer,
camera,
modelTF,
modelInvTF: mat3.invert([], modelTF),
projectionTF,
});
}
componentDidUpdate(prevProps, prevState) {
const { renderCache } = this;
const {
world,
crossfilter,
colorRGB,
selectionTool,
currentSelection,
layoutChoice,
graphInteractionMode,
pointDilation,
colorAccessor,
} = this.props;
const { regl, toolSVG, camera, modelTF, viewport } = this.state;
const { toolSVG, viewport } = this.state;
let { projectionTF } = this.state;
const hasResized =
prevState.viewport.height !== this.reglCanvas.height ||
prevState.viewport.width !== this.reglCanvas.width;
prevState.viewport.height !== viewport.height ||
prevState.viewport.width !== viewport.width;
let stateChanges = {};
let needsRepaint = hasResized;
if (regl && world && crossfilter) {
/* update the regl and point rendering state */
const { obsLayout, nObs } = world;
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
if (hasResized) {
projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
stateChanges = {
...stateChanges,
projectionTF,
};
}
/* coordinates for each point */
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
const newPositions = this.computePointPositions(X, Y, modelTF);
if (renderCache.positions !== newPositions) {
/* update our cache & GL if the buffer changes */
renderCache.positions = newPositions;
pointBuffer({ data: newPositions, dimension: 2 });
needsRepaint = true;
}
/* colors for each point */
const newColors = this.computePointColors(colorRGB);
if (renderCache.colors !== newColors) {
/* update our cache & GL if the buffer changes */
renderCache.colors = newColors;
colorBuffer({ data: newColors, dimension: 3 });
needsRepaint = true;
}
/* flags for each point */
const newFlags = this.computePointFlags(
world,
crossfilter,
colorAccessor,
pointDilation
);
if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags;
needsRepaint = true;
flagBuffer({ data: newFlags, dimension: 1 });
}
this.count = nObs;
if (needsRepaint) {
this.renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
);
}
}
if (hasResized) {
// If the window size has changed we want to recreate all SVGs
projectionTF = createProjectionTF(viewport.width, viewport.height);
stateChanges = {
...stateChanges,
...this.createToolSVG(),
projectionTF,
};
} else if (
(viewport.height && viewport.width && !toolSVG) ||
selectionTool !== prevProps.selectionTool
}
if (
(viewport.height && viewport.width && !toolSVG) || // first time init
hasResized || // window size has changed we want to recreate all SVGs
selectionTool !== prevProps.selectionTool || // change of selection tool
prevProps.graphInteractionMode !== graphInteractionMode // lasso/zoom mode is switched
) {
// first time or change of selection tool
stateChanges = { ...stateChanges, ...this.createToolSVG() };
} else if (prevProps.graphInteractionMode !== graphInteractionMode) {
// If lasso/zoom is switched
stateChanges = {
...stateChanges,
...this.createToolSVG(),
@@ -367,6 +288,13 @@ class Graph extends React.Component {
window.removeEventListener("resize", this.handleResize);
}
setReglCanvas = (canvas) => {
this.reglCanvas = canvas;
this.setState({
...Graph.createReglState(canvas),
});
};
handleResize = () => {
const { state } = this.state;
const viewport = this.getViewportDimensions();
@@ -400,11 +328,13 @@ class Graph extends React.Component {
Called from componentDidUpdate. Create the tool SVG, and return any
state changes that should be passed to setState().
*/
const { viewport, selectionTool, graphInteractionMode } = this.props;
const { selectionTool, graphInteractionMode } = this.props;
const { viewport } = this.state;
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#lasso-layer").selectAll(".lasso-group").remove();
const lasso = d3.select("#lasso-layer");
if (lasso.empty()) return {}; // still initializing
lasso.selectAll(".lasso-group").remove();
// Don't render or recreate toolSVG if currently in zoom mode
if (graphInteractionMode !== "select") {
@@ -440,6 +370,88 @@ class Graph extends React.Component {
return { toolSVG: newToolSVG, tool, container };
};
fetchAsyncProps = async (props) => {
const {
annoMatrix,
colors: colorsProp,
layoutChoice,
crossfilter,
pointDilation,
viewport,
} = props.watchProps;
const { modelTF } = this.state;
const [layoutDf, colorDf, pointDilationDf] = await this.fetchData(
annoMatrix,
layoutChoice,
colorsProp,
pointDilation
);
const { currentDimNames } = layoutChoice;
const X = layoutDf.col(currentDimNames[0]).asArray();
const Y = layoutDf.col(currentDimNames[1]).asArray();
const positions = this.computePointPositions(X, Y, modelTF);
const colorTable = this.updateColorTable(colorsProp, colorDf);
const colors = this.computePointColors(colorTable.rgb);
const { colorAccessor } = colorsProp;
const colorByData = colorDf?.col(colorAccessor)?.asArray();
const {
metadataField: pointDilationCategory,
categoryField: pointDilationLabel,
} = pointDilation;
const pointDilationData = pointDilationDf
?.col(pointDilationCategory)
?.asArray();
const flags = this.computePointFlags(
crossfilter,
colorByData,
pointDilationData,
pointDilationLabel
);
const { width, height } = viewport;
return {
positions,
colors,
flags,
width,
height,
};
};
async fetchData(annoMatrix, layoutChoice, colors, pointDilation) {
/*
fetch all data needed. Includes:
- the color by dataframe
- the layout dataframe
- the point dilation dataframe
*/
const { metadataField: pointDilationAccessor } = pointDilation;
const promises = [];
// layout
promises.push(annoMatrix.fetch("emb", layoutChoice.current));
// color
const query = this.createColorByQuery(colors);
if (query) {
promises.push(annoMatrix.fetch(...query));
} else {
promises.push(Promise.resolve(null));
}
// point highlighting
if (pointDilationAccessor) {
promises.push(annoMatrix.fetch("obs", pointDilationAccessor));
} else {
promises.push(Promise.resolve(null));
}
return Promise.all(promises);
}
brushToolUpdate(tool, container) {
/*
this is called from componentDidUpdate(), so be very careful using
@@ -571,17 +583,22 @@ class Graph extends React.Component {
// ignore programatically generated events
if (d3.event.sourceEvent === null || !d3.event.selection) return;
const { dispatch } = this.props;
const { dispatch, layoutChoice } = this.props;
const s = d3.event.selection;
const brushCoords = {
northwest: this.mapScreenToPoint([s[0][0], s[0][1]]),
southeast: this.mapScreenToPoint([s[1][0], s[1][1]]),
};
dispatch({
type: "graph brush change",
brushCoords,
});
const northwest = this.mapScreenToPoint(s[0]);
const southeast = this.mapScreenToPoint(s[1]);
const [minX, maxY] = northwest;
const [maxX, minY] = southeast;
dispatch(
actions.graphBrushChangeAction(layoutChoice.current, {
minX,
minY,
maxX,
maxY,
northwest,
southeast,
})
);
}
handleBrushStartAction() {
@@ -589,7 +606,7 @@ class Graph extends React.Component {
if (!d3.event.sourceEvent) return;
const { dispatch } = this.props;
dispatch({ type: "graph brush start" });
dispatch(actions.graphBrushStartAction());
}
handleBrushEndAction() {
@@ -600,65 +617,67 @@ class Graph extends React.Component {
coordinates will be included if selection made, null
if selection cleared.
*/
const { dispatch } = this.props;
const { dispatch, layoutChoice } = this.props;
const s = d3.event.selection;
if (s) {
const brushCoords = {
northwest: this.mapScreenToPoint(s[0]),
southeast: this.mapScreenToPoint(s[1]),
};
dispatch({
type: "graph brush end",
brushCoords,
});
const northwest = this.mapScreenToPoint(s[0]);
const southeast = this.mapScreenToPoint(s[1]);
const [minX, maxY] = northwest;
const [maxX, minY] = southeast;
dispatch(
actions.graphBrushEndAction(layoutChoice.current, {
minX,
minY,
maxX,
maxY,
northwest,
southeast,
})
);
} else {
dispatch({
type: "graph brush deselect",
});
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
}
}
handleBrushDeselectAction() {
const { dispatch } = this.props;
dispatch({
type: "graph brush deselect",
});
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
}
handleLassoStart() {
const { dispatch } = this.props;
dispatch({
type: "graph lasso start",
});
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoStartAction(layoutChoice.current));
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const minimumPolygonArea = 10;
const { dispatch } = this.props;
const { dispatch, layoutChoice } = this.props;
if (
polygon.length < 3 ||
Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea
) {
// if less than three points, or super small area, treat as a clear selection.
dispatch({ type: "graph lasso deselect" });
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
} else {
dispatch({
type: "graph lasso end",
polygon: polygon.map((xy) => this.mapScreenToPoint(xy)), // transform the polygon
});
dispatch(
actions.graphLassoEndAction(
layoutChoice.current,
polygon.map((xy) => this.mapScreenToPoint(xy))
)
);
}
}
handleLassoCancel() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso cancel" });
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoCancelAction(layoutChoice.current));
}
handleLassoDeselectAction() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso deselect" });
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
}
handleDeselectAction() {
@@ -675,38 +694,6 @@ class Graph extends React.Component {
});
}
renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
) {
const { universe } = this.props;
if (!this.reglCanvas || !universe) return;
const cameraTF = camera.view();
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
const { width, height } = this.reglCanvas;
regl.poll();
regl.clear({
depth: 1,
color: [1, 1, 1, 1],
});
drawPoints({
distance: camera.distance(),
color: colorBuffer,
position: pointBuffer,
flag: flagBuffer,
count: this.count,
projView,
nPoints: universe.nObs,
minViewportDimension: Math.min(width, height),
});
regl._gl.flush();
}
renderCanvas = renderThrottle(() => {
const {
regl,
@@ -728,9 +715,92 @@ class Graph extends React.Component {
);
});
updateReglAndRender(asyncProps) {
const { positions, colors, flags } = asyncProps;
this.cachedAsyncProps = asyncProps;
const { pointBuffer, colorBuffer, flagBuffer } = this.state;
pointBuffer({ data: positions, dimension: 2 });
colorBuffer({ data: colors, dimension: 3 });
flagBuffer({ data: flags, dimension: 1 });
this.renderCanvas();
}
updateColorTable(colors, colorDf) {
const { annoMatrix } = this.props;
const { schema } = annoMatrix;
/* update color table state */
if (!colors || !colorDf) {
return createColorTable(
null, // default mode
null,
null,
schema,
null
);
}
const { colorAccessor, userColors, colorMode } = colors;
return createColorTable(
colorMode,
colorAccessor,
colorDf,
schema,
userColors
);
}
createColorByQuery(colors) {
const { annoMatrix } = this.props;
const { schema } = annoMatrix;
const { colorMode, colorAccessor } = colors;
return createColorQuery(colorMode, colorAccessor, schema);
}
renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
) {
const { annoMatrix } = this.props;
if (!this.reglCanvas || !annoMatrix) return;
const { schema } = annoMatrix;
const cameraTF = camera.view();
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
const { width, height } = this.reglCanvas;
regl.poll();
regl.clear({
depth: 1,
color: [1, 1, 1, 1],
});
drawPoints({
distance: camera.distance(),
color: colorBuffer,
position: pointBuffer,
flag: flagBuffer,
count: annoMatrix.nObs,
projView,
nPoints: schema.dataframe.nObs,
minViewportDimension: Math.min(width, height),
});
regl._gl.flush();
}
render() {
const { graphInteractionMode } = this.props;
const { modelTF, projectionTF, camera, viewport } = this.state;
const {
graphInteractionMode,
annoMatrix,
colors,
layoutChoice,
pointDilation,
crossfilter,
} = this.props;
const { modelTF, projectionTF, camera, viewport, regl } = this.state;
const cameraTF = camera?.view()?.slice();
return (
@@ -781,18 +851,65 @@ class Graph extends React.Component {
}}
className="graph-canvas"
data-testid="layout-graph"
ref={(canvas) => {
this.reglCanvas = canvas;
}}
ref={this.setReglCanvas}
onMouseDown={this.handleCanvasEvent}
onMouseUp={this.handleCanvasEvent}
onMouseMove={this.handleCanvasEvent}
onDoubleClick={this.handleCanvasEvent}
onWheel={this.handleCanvasEvent}
/>
<Async
watchFn={Graph.watchAsync}
promiseFn={this.fetchAsyncProps}
watchProps={{
annoMatrix,
colors,
layoutChoice,
pointDilation,
crossfilter,
viewport,
}}
>
<Async.Pending initial>Embedding loading...</Async.Pending>
<Async.Rejected>
{(error) => (
<ErrorLoading
displayName={layoutChoice.current}
error={error}
width={viewport.width}
height={viewport.height}
/>
)}
</Async.Rejected>
<Async.Fulfilled>
{(asyncProps) => {
if (regl && !shallowEqual(asyncProps, this.cachedAsyncProps)) {
this.updateReglAndRender(asyncProps);
}
return null;
}}
</Async.Fulfilled>
</Async>
</div>
);
}
}
const ErrorLoading = ({ displayName, error, width, height }) => {
console.log(error); // log to console as this is an unepected error
return (
<div
style={{
position: "fixed",
fontWeight: 500,
top: height / 2,
left: globals.leftSidebarWidth + width / 2 - 50,
}}
>
<span>{`Failure loading ${displayName}`}</span>
</div>
);
};
export default Graph;
@@ -1,113 +1,217 @@
import React, { PureComponent } from "react";
import { connect } from "react-redux";
import { connect, shallowEqual } from "react-redux";
import Async from "react-async";
import { categoryLabelDisplayStringLongLength } from "../../../globals";
import calcCentroid from "../../../util/centroid";
import { createColorQuery } from "../../../util/stateManager/colorHelpers";
export default
@connect((state) => ({
colorAccessor: state.colors.colorAccessor,
annoMatrix: state.annoMatrix,
colors: state.colors,
layoutChoice: state.layoutChoice,
dilatedValue: state.pointDilation.categoryField,
labels: state.centroidLabels.labels,
categoricalSelection: state.categoricalSelection,
showLabels: state.centroidLabels?.showLabels,
}))
class CentroidLabels extends PureComponent {
// Check to see if centroids have either just been displayed or removed from the overlay
static watchAsync(props, prevProps) {
return !shallowEqual(props.watchProps, prevProps.watchProps);
}
componentDidUpdate(prevProps) {
const { labels, overlayToggled } = this.props;
const prevSize = prevProps.labels.size;
const { size } = labels;
fetchAsyncProps = async (props) => {
const {
annoMatrix,
colors,
layoutChoice,
categoricalSelection,
showLabels,
} = props.watchProps;
const { schema } = annoMatrix;
const { colorAccessor } = colors;
const displayChangeOff = prevSize > 0 && size === undefined;
const displayChangeOn = prevSize === undefined && size > 0;
if (displayChangeOn || displayChangeOff) {
// Notify overlay layer of display change
overlayToggled("centroidLabels", displayChangeOn);
const [layoutDf, colorDf] = await this.fetchData();
let labels;
if (colorDf) {
labels = calcCentroid(
schema,
colorAccessor,
colorDf,
layoutChoice,
layoutDf
);
} else {
labels = new Map();
}
const { overlaySetShowing } = this.props;
overlaySetShowing("centroidLabels", showLabels && labels.size > 0);
return {
labels,
colorAccessor,
category: categoricalSelection[colorAccessor],
};
};
handleMouseEnter = (e, colorAccessor, label) => {
const { dispatch } = this.props;
dispatch({
type: "category value mouse hover start",
metadataField: colorAccessor,
categoryField: label,
});
};
handleMouseOut = (e, colorAccessor, label) => {
const { dispatch } = this.props;
dispatch({
type: "category value mouse hover end",
metadataField: colorAccessor,
categoryField: label,
});
};
colorByQuery() {
const { annoMatrix, colors } = this.props;
const { schema } = annoMatrix;
const { colorMode, colorAccessor } = colors;
return createColorQuery(colorMode, colorAccessor, schema);
}
async fetchData() {
const { annoMatrix, layoutChoice } = this.props;
// fetch all data we need: layout, category
const promises = [];
// layout
promises.push(annoMatrix.fetch("emb", layoutChoice.current));
// category to label - we ONLY label on obs, never on X, etc.
const query = this.colorByQuery();
if (query && query[0] === "obs") {
promises.push(annoMatrix.fetch(...query));
} else {
promises.push(Promise.resolve(null));
}
return Promise.all(promises);
}
render() {
const {
labels,
inverseTransform,
dilatedValue,
dispatch,
colorAccessor,
categoricalSelection,
showLabels,
colors,
annoMatrix,
layoutChoice,
} = this.props;
if (!colorAccessor || labels.size === undefined || labels.size === 0)
return null;
return (
<Async
watchFn={CentroidLabels.watchAsync}
promiseFn={this.fetchAsyncProps}
watchProps={{
annoMatrix,
colors,
layoutChoice,
categoricalSelection,
dilatedValue,
showLabels,
}}
>
<Async.Fulfilled>
{(asyncProps) => {
if (!showLabels) return null;
const category = categoricalSelection[colorAccessor];
const labelSVGS = [];
const deselectOpacity = 0.375;
const { category, colorAccessor, labels } = asyncProps;
const labelSVGS = [];
let fontSize = "15px";
let fontWeight = null;
const deselectOpacity = 0.375;
labels.forEach((coords, label) => {
fontSize = "15px";
fontWeight = null;
if (label === dilatedValue) {
fontSize = "18px";
fontWeight = "800";
}
labels.forEach((coords, label) => {
const selected = category.get(label) ?? true;
const selected = category.get(label) ?? true;
// Mirror LSB middle truncation
let displayLabel = label;
if (displayLabel.length > categoryLabelDisplayStringLongLength) {
displayLabel = `${label.slice(
0,
categoryLabelDisplayStringLongLength / 2
)}…${label.slice(-categoryLabelDisplayStringLongLength / 2)}`;
}
// Mirror LSB middle truncation
let displayLabel = label;
if (displayLabel.length > categoryLabelDisplayStringLongLength) {
displayLabel = `${label.slice(
0,
categoryLabelDisplayStringLongLength / 2
)}…${label.slice(-categoryLabelDisplayStringLongLength / 2)}`;
}
labelSVGS.push(
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events -- the mouse actions for centroid labels do not have a screen reader alternative
<Label
key={label} // eslint-disable-line react/no-array-index-key --- label is not an index, eslint is confused
label={label}
dilatedValue={dilatedValue}
coords={coords}
inverseTransform={inverseTransform}
opactity={selected ? 1 : deselectOpacity}
colorAccessor={colorAccessor}
displayLabel={displayLabel}
onMouseEnter={this.handleMouseEnter}
onMouseOut={this.handleMouseOut}
/>
);
});
labelSVGS.push(
<g
// eslint-disable-next-line react/no-array-index-key --- label is unique and consistent
key={label}
className="centroid-label"
transform={`translate(${coords[0]}, ${coords[1]})`}
data-testclass="centroid-label"
data-testid={`${label}-centroid-label`}
>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events --- the mouse actions for centroid labels do not have a screen reader alternative*/}
<text
transform={inverseTransform}
textAnchor="middle"
data-label={label}
style={{
fontSize,
fontWeight,
fill: "black",
userSelect: "none",
opacity: selected ? 1 : deselectOpacity,
}}
onMouseEnter={(e) =>
dispatch({
type: "category value mouse hover start",
metadataField: colorAccessor,
categoryField: e.target.getAttribute("data-label"),
})
}
onMouseOut={(e) =>
dispatch({
type: "category value mouse hover end",
metadataField: colorAccessor,
categoryField: e.target.getAttribute("data-label"),
})
}
pointerEvents="visiblePainted"
>
{displayLabel}
</text>
</g>
);
});
return <>{labelSVGS}</>;
return <>{labelSVGS}</>;
}}
</Async.Fulfilled>
</Async>
);
}
}
const Label = ({
label,
dilatedValue,
coords,
inverseTransform,
opacity,
colorAccessor,
displayLabel,
onMouseEnter,
onMouseOut,
}) => {
/*
Render a label at a given coordinate.
*/
let fontSize = "15px";
let fontWeight = null;
if (label === dilatedValue) {
fontSize = "18px";
fontWeight = "800";
}
return (
<g
key={label}
className="centroid-label"
transform={`translate(${coords[0]}, ${coords[1]})`}
data-testclass="centroid-label"
data-testid={`${label}-centroid-label`}
>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events --- the mouse actions for centroid labels do not have a screen reader alternative*/}
<text
transform={inverseTransform}
textAnchor="middle"
style={{
fontSize,
fontWeight,
fill: "black",
userSelect: "none",
opacity: { opacity },
}}
onMouseEnter={(e) => onMouseEnter(e, colorAccessor, label)}
onMouseOut={(e) => onMouseOut(e, colorAccessor, label)}
pointerEvents="visiblePainted"
>
{displayLabel}
</text>
</g>
);
};
@@ -33,7 +33,7 @@ export default class GraphOverlayLayer extends PureComponent {
};
// This is passed to all children, should be called when an overlay's display state is toggled along with the overlay name and its new display state in boolean form
overlayToggled = (overlay, displaying) => {
overlaySetShowing = (overlay, displaying) => {
this.setState((state) => {
return { ...state, display: { ...state.display, [overlay]: displaying } };
});
@@ -67,7 +67,7 @@ export default class GraphOverlayLayer extends PureComponent {
const newChildren = React.Children.map(children, (child) =>
cloneElement(child, {
inverseTransform,
overlayToggled: this.overlayToggled,
overlaySetShowing: this.overlaySetShowing,
})
);
@@ -17,6 +17,7 @@ export default (
viewport
) => {
const svg = d3.select("#graph-wrapper").select("#lasso-layer");
if (svg.empty()) return {};
if (selectionToolType === "brush") {
const brush = d3
@@ -2,7 +2,6 @@
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import { World } from "../../util/stateManager";
import { tooltipHoverOpenDelay } from "../../globals";
@connect()
@@ -15,12 +14,8 @@ class CellSetButton extends React.PureComponent {
eitherCellSetOneOrTwo,
} = this.props;
// Reducer and components assume that value will be null if
// no selection made. World..getSelectedByIndex() returns a
// zero length TypedArray when nothing is selected.
let set = World.getSelectedByIndex(crossfilter);
let set = crossfilter.allSelectedLabels();
if (set.length === 0) set = null;
if (!differential.diffExp) {
/* diffexp needs to be cleared before we store a new set */
dispatch({
@@ -9,7 +9,7 @@ import CellSetButton from "./cellSetButtons";
@connect((state) => ({
config: state.config,
crossfilter: state.crossfilter,
crossfilter: state.obsCrossfilter,
differential: state.differential,
celllist1: state.differential?.celllist1,
celllist2: state.differential?.celllist2,
+9 -10
View File
@@ -1,6 +1,5 @@
import React from "react";
import {
AnchorButton,
ButtonGroup,
Popover,
Button,
@@ -12,26 +11,25 @@ import {
import { connect } from "react-redux";
import * as globals from "../../globals";
import styles from "./menubar.css";
import { World } from "../../util/stateManager";
import actions from "../../actions";
@connect((state) => ({
universe: state.universe,
world: state.world,
layoutChoice: state.layoutChoice,
reembedController: state.reembedController,
enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false,
// disabled temporarily. TODO - issue #1606
// reembedController: state.reembedController,
// enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false,
enableReembedding: false,
}))
class Embedding extends React.PureComponent {
handleLayoutChoiceChange = (e) => {
const { dispatch } = this.props;
dispatch({
type: "set layout choice",
layoutChoice: e.currentTarget.value,
});
dispatch(actions.layoutChoiceAction(e.currentTarget.value));
};
// eslint-disable-next-line class-methods-use-this -- temporary disable
renderReembedding() {
return null;
/* disabled pending rewrite. TODO - issue #1606
const {
enableReembedding,
world,
@@ -63,6 +61,7 @@ class Embedding extends React.PureComponent {
/>
</Tooltip>
);
*/
}
render() {
+15 -22
View File
@@ -1,4 +1,3 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import { ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
@@ -14,13 +13,12 @@ import UndoRedoReset from "./undoRedo";
import DiffexpButtons from "./diffexpButtons";
@connect((state) => ({
universe: state.universe,
world: state.world,
crossfilter: state.crossfilter,
annoMatrix: state.annoMatrix,
crossfilter: state.obsCrossfilter,
differential: state.differential,
graphInteractionMode: state.controls.graphInteractionMode,
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
clipPercentileMin: Math.round(100 * (state.annoMatrix?.clipRange?.[0] ?? 0)),
clipPercentileMax: Math.round(100 * (state.annoMatrix?.clipRange?.[1] ?? 1)),
userDefinedGenes: state.controls.userDefinedGenes,
diffexpGenes: state.controls.diffexpGenes,
colorAccessor: state.colors.colorAccessor,
@@ -78,10 +76,10 @@ class MenuBar extends React.Component {
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
const { world } = this.props;
const currentClipMin = 100 * world?.clipQuantiles?.min;
const currentClipMax = 100 * world?.clipQuantiles?.max;
const {
clipPercentileMin: currentClipMin,
clipPercentileMax: currentClipMax,
} = this.props;
// if you change this test, be careful with logic around
// comparisons between undefined / NaN handling.
@@ -150,10 +148,7 @@ class MenuBar extends React.Component {
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
const min = clipPercentileMin / 100;
const max = clipPercentileMax / 100;
dispatch({
type: "set clip quantiles",
clipQuantiles: { min, max },
});
dispatch(actions.clipAction(min, max));
};
handleClipOpening = () => {
@@ -178,15 +173,15 @@ class MenuBar extends React.Component {
subsetPossible = () => {
const { crossfilter } = this.props;
const count = crossfilter.countSelected();
return (
crossfilter.countSelected() !== 0 &&
crossfilter.countSelected() !== crossfilter.size()
count !== 0 && count !== crossfilter.size() // ie, not all are selected
);
};
subsetResetPossible = () => {
const { world, universe } = this.props;
return world.nObs !== universe.nObs;
const { annoMatrix } = this.props;
return annoMatrix.nObs !== annoMatrix.schema.dataframe.nObs;
};
render() {
@@ -317,12 +312,10 @@ class MenuBar extends React.Component {
subsetPossible={this.subsetPossible()}
subsetResetPossible={this.subsetResetPossible()}
handleSubset={() => {
dispatch(actions.setWorldToSelection());
dispatch({ type: "increment graph render counter" });
dispatch(actions.subsetAction());
}}
handleSubsetReset={() => {
dispatch(actions.resetWorldToUniverse());
dispatch({ type: "increment graph render counter" });
dispatch(actions.resetSubsetAction());
}}
/>
{disableDiffexp ? null : <DiffexpButtons />}
@@ -14,6 +14,9 @@ export default class MiniHistogram extends React.PureComponent {
drawHistogram = () => {
const { xScale, yScale, bins, width, height } = this.props;
if (!bins) return;
const ctx = this.canvasRef.current.getContext("2d");
ctx.clearRect(0, 0, width, height);
@@ -12,12 +12,16 @@ export default class MiniStackedBar extends React.PureComponent {
domainValues,
scale,
domain,
colorScale,
colorTable,
occupancy,
width,
height,
} = this.props;
if (!colorTable || !domainValues) return;
const { scale: colorScale } = colorTable;
const ctx = this.canvasRef?.current.getContext("2d");
ctx.clearRect(0, 0, width, height);
+365 -232
View File
@@ -1,18 +1,25 @@
import React from "react";
import { connect } from "react-redux";
import React, { useEffect, useRef } from "react";
import { connect, shallowEqual } from "react-redux";
import { Button, ButtonGroup } from "@blueprintjs/core";
import _regl from "regl";
import * as d3 from "d3";
import { mat3 } from "gl-matrix";
import memoize from "memoize-one";
import { isTypedArray } from "../../util/typeHelpers";
import Async from "react-async";
import * as globals from "../../globals";
import setupScatterplot from "./setupScatterplot";
import styles from "./scatterplot.css";
import _drawPoints from "./drawPointsRegl";
import { margin, width, height } from "./util";
import finiteExtent from "../../util/finiteExtent";
import {
createColorTable,
createColorQuery,
} from "../../util/stateManager/colorHelpers";
import renderThrottle from "../../util/renderThrottle";
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
function createProjectionTF(viewportWidth, viewportHeight) {
/*
@@ -22,40 +29,58 @@ function createProjectionTF(viewportWidth, viewportHeight) {
return mat3.projection(m, viewportWidth, viewportHeight);
}
function getScale(col, rangeMin, rangeMax) {
if (!col) return null;
const { min, max } = col.summarize();
return d3.scaleLinear().domain([min, max]).range([rangeMin, rangeMax]);
}
const getXScale = memoize(getScale);
const getYScale = memoize(getScale);
@connect((state) => {
const { world, crossfilter, universe } = state;
const { obsCrossfilter: crossfilter } = state;
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
const expressionX = scatterplotXXaccessor
? world.varData.col(scatterplotXXaccessor)?.asArray()
: null;
const expressionY = scatterplotYYaccessor
? world.varData.col(scatterplotYYaccessor)?.asArray()
: null;
return {
world,
universe,
colorRGB: state.colors.rgb,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
annoMatrix: state.annoMatrix,
colors: state.colors,
pointDilation: state.pointDilation,
// Accessors are var/gene names (strings)
scatterplotXXaccessor,
scatterplotYYaccessor,
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
differential: state.differential,
expressionX,
expressionY,
crossfilter,
};
})
class Scatterplot extends React.PureComponent {
static createReglState(canvas) {
/*
Must be created for each canvas
*/
// setup canvas, webgl draw function and camera
const regl = _regl(canvas);
const drawPoints = _drawPoints(regl);
// preallocate webgl buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const flagBuffer = regl.buffer();
return {
regl,
drawPoints,
pointBuffer,
colorBuffer,
flagBuffer,
};
}
static watchAsync(props, prevProps) {
return !shallowEqual(props.watchProps, prevProps.watchProps);
}
computePointPositions = memoize((X, Y, xScale, yScale) => {
const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) {
@@ -77,21 +102,31 @@ class Scatterplot extends React.PureComponent {
});
computeSelectedFlags = memoize(
(crossfilter, flagSelected, flagUnselected) => {
(crossfilter, _flagSelected, _flagUnselected) => {
const x = crossfilter.fillByIsSelected(
new Float32Array(crossfilter.size()),
flagSelected,
flagUnselected
_flagSelected,
_flagUnselected
);
return x;
}
);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, pointDilation) => {
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
(crossfilter, colorByData, pointDilationData, pointDilationLabel) => {
/*
We communicate with the shader using three flags:
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
- isSelected -- the value is selected
- isHightlighted -- the value is highlighted in the UI (orthogonal from selection highlighting)
Due to constraints in webgl vertex shader attributes, these are encoded in a float, "kinda"
like bitmasks.
We also have separate code paths for generating flags for categorical and
continuous metadata, as they rely on different tests, and some of the flags
(eg, isNaN) are meaningless in the face of categorical metadata.
*/
const flags = this.computeSelectedFlags(
crossfilter,
@@ -99,21 +134,11 @@ class Scatterplot extends React.PureComponent {
0
).slice();
const { metadataField, categoryField } = pointDilation;
const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray()
: null;
const colorByColumn = colorAccessor
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
world.varData.col(colorAccessor)?.asArray()
: null;
const colorByData =
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
if (colorByData || highlightData) {
if (colorByData || pointDilationData) {
for (let i = 0, len = flags.length; i < len; i += 1) {
if (highlightData) {
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
if (pointDilationData) {
flags[i] +=
pointDilationData[i] === pointDilationLabel ? flagHighlight : 0;
}
if (colorByData) {
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
@@ -126,162 +151,48 @@ class Scatterplot extends React.PureComponent {
constructor(props) {
super(props);
this.count = 0;
this.axes = false;
this.renderCache = {
positions: null,
colors: null,
flags: null,
xScale: null,
yScale: null,
};
this.reglCanvas = null;
this.renderCache = null;
this.state = {
svg: null,
regl: null,
drawPoints: null,
minimized: null,
viewport: {
height: null,
width: null,
},
projectionTF: null,
};
}
componentDidMount() {
const { svg } = setupScatterplot(width, height, margin);
let scales;
const { expressionX, expressionY } = this.props;
if (svg && expressionX && expressionY) {
scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
}
const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl);
// Create render transform
const projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
// preallocate buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const flagBuffer = regl.buffer();
this.renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
);
window.addEventListener("resize", this.handleResize);
const viewport = this.getViewportDimensions();
this.setState({
regl,
flagBuffer,
pointBuffer,
colorBuffer,
svg,
drawPoints,
projectionTF,
viewport,
});
}
componentDidUpdate(prevProps) {
const {
world,
crossfilter,
scatterplotXXaccessor,
scatterplotYYaccessor,
expressionX,
expressionY,
colorRGB,
colorAccessor,
pointDilation,
} = this.props;
const {
regl,
pointBuffer,
colorBuffer,
flagBuffer,
svg,
drawPoints,
projectionTF,
} = this.state;
if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor ||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor ||
world !== prevProps.world // shape or clip of world changed
) {
const scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
}
if (world && regl) {
const { renderCache } = this;
const { xScale, yScale } = this.renderCache;
let needsRepaint = false;
const newPositions = this.computePointPositions(
expressionX,
expressionY,
xScale,
yScale
);
if (renderCache.positions !== newPositions) {
renderCache.positions = newPositions;
pointBuffer({ data: renderCache.positions, dimension: 2 });
needsRepaint = true;
}
/* colors for each point */
const newColors = this.computePointColors(colorRGB);
if (renderCache.colors !== newColors) {
renderCache.colors = newColors;
colorBuffer({ data: renderCache.colors, dimension: 3 });
needsRepaint = true;
}
const newFlags = this.computePointFlags(
world,
crossfilter,
colorAccessor,
pointDilation
);
if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags;
flagBuffer({ data: renderCache.flags, dimension: 1 });
needsRepaint = true;
}
this.count = expressionX.length;
if (needsRepaint) {
this.renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
);
}
}
}
componentWillUnmount() {
window.removeEventListener("resize", this.updateViewportDimensions);
}
setReglCanvas = (canvas) => {
this.reglCanvas = canvas;
this.setState({
...Scatterplot.createReglState(canvas),
});
};
getViewportDimensions = () => {
return {
viewport: {
@@ -291,22 +202,6 @@ class Scatterplot extends React.PureComponent {
};
};
static setupScales(expressionX, expressionY) {
const xScale = d3
.scaleLinear()
.domain(finiteExtent(expressionX))
.range([0, width]);
const yScale = d3
.scaleLinear()
.domain(finiteExtent(expressionY))
.range([height, 0]);
return {
xScale,
yScale,
};
}
handleResize = () => {
const { state } = this.state;
const viewport = this.getViewportDimensions();
@@ -320,48 +215,167 @@ class Scatterplot extends React.PureComponent {
this.setState(this.getViewportDimensions());
};
drawAxesSVG(xScale, yScale, svg) {
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
svg.selectAll("*").remove();
fetchAsyncProps = async (props) => {
const {
scatterplotXXaccessor,
scatterplotYYaccessor,
colors: colorsProp,
crossfilter,
pointDilation,
} = props.watchProps;
// the axes are much cleaner and easier now. No need to rotate and orient
// the axis, just call axisBottom, axisLeft etc.
const xAxis = d3.axisBottom().ticks(7).scale(xScale);
const [
expressionXDf,
expressionYDf,
colorDf,
pointDilationDf,
] = await this.fetchData(
scatterplotXXaccessor,
scatterplotYYaccessor,
colorsProp,
pointDilation
);
const colorTable = this.updateColorTable(colorsProp, colorDf);
const yAxis = d3.axisLeft().ticks(7).scale(yScale);
const xCol = expressionXDf.icol(0);
const yCol = expressionYDf.icol(0);
const xScale = getXScale(xCol, 0, width);
const yScale = getYScale(yCol, height, 0);
const positions = this.computePointPositions(
xCol.asArray(),
yCol.asArray(),
xScale,
yScale
);
// adding axes is also simpler now, just translate x-axis to (0,height)
// and it's alread defined to be a bottom axis.
svg
.append("g")
.attr("transform", `translate(0,${height})`)
.attr("class", "x axis")
.call(xAxis);
const colors = this.computePointColors(colorTable.rgb);
// y-axis is translated to (0,0)
svg
.append("g")
.attr("transform", "translate(0,0)")
.attr("class", "y axis")
.call(yAxis);
const { colorAccessor } = colorsProp;
const colorByData = colorDf?.col(colorAccessor)?.asArray();
const {
metadataField: pointDilationCategory,
categoryField: pointDilationLabel,
} = pointDilation;
const pointDilationData = pointDilationDf
?.col(pointDilationCategory)
?.asArray();
const flags = this.computePointFlags(
crossfilter,
colorByData,
pointDilationData,
pointDilationLabel
);
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
svg
.append("text")
.attr("x", 10)
.attr("y", 10)
.attr("class", "label")
.style("font-style", "italic")
.text(scatterplotYYaccessor);
return {
positions,
colors,
flags,
width,
height,
xScale,
yScale,
};
};
svg
.append("text")
.attr("x", width)
.attr("y", height - 10)
.attr("text-anchor", "end")
.attr("class", "label")
.style("font-style", "italic")
.text(scatterplotXXaccessor);
createXQuery(geneName) {
const { annoMatrix } = this.props;
const { schema } = annoMatrix;
const varIndex = schema?.annotations?.var?.index;
if (!varIndex) return null;
return [
"X",
{
field: "var",
column: varIndex,
value: geneName,
},
];
}
createColorByQuery(colors) {
const { annoMatrix } = this.props;
const { schema } = annoMatrix;
const { colorMode, colorAccessor } = colors;
return createColorQuery(colorMode, colorAccessor, schema);
}
updateColorTable(colors, colorDf) {
/* update color table state */
const { annoMatrix } = this.props;
const { schema } = annoMatrix;
const { colorAccessor, userColors, colorMode } = colors;
return createColorTable(
colorMode,
colorAccessor,
colorDf,
schema,
userColors
);
}
async fetchData(
scatterplotXXaccessor,
scatterplotYYaccessor,
colors,
pointDilation
) {
const { annoMatrix } = this.props;
const { metadataField: pointDilationAccessor } = pointDilation;
const promises = [];
// X and Y dimensions
promises.push(
annoMatrix.fetch(...this.createXQuery(scatterplotXXaccessor))
);
promises.push(
annoMatrix.fetch(...this.createXQuery(scatterplotYYaccessor))
);
// color
const query = this.createColorByQuery(colors);
if (query) {
promises.push(annoMatrix.fetch(...query));
} else {
promises.push(Promise.resolve(null));
}
// point highlighting
if (pointDilationAccessor) {
promises.push(annoMatrix.fetch("obs", pointDilationAccessor));
} else {
promises.push(Promise.resolve(null));
}
return Promise.all(promises);
}
renderCanvas = renderThrottle(() => {
const {
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
projectionTF,
} = this.state;
this.renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
);
});
updateReglAndRender(newRenderCache) {
const { positions, colors, flags } = newRenderCache;
this.renderCache = newRenderCache;
const { pointBuffer, colorBuffer, flagBuffer } = this.state;
pointBuffer({ data: positions, dimension: 2 });
colorBuffer({ data: colors, dimension: 3 });
flagBuffer({ data: flags, dimension: 1 });
this.renderCanvas();
}
renderPoints(
@@ -372,8 +386,10 @@ class Scatterplot extends React.PureComponent {
pointBuffer,
projectionTF
) {
if (!this.reglCanvas) return;
const { universe } = this.props;
const { annoMatrix } = this.props;
if (!this.reglCanvas || !annoMatrix) return;
const { schema } = annoMatrix;
const { viewport } = this.state;
regl.poll();
regl.clear({
@@ -385,8 +401,8 @@ class Scatterplot extends React.PureComponent {
color: colorBuffer,
position: pointBuffer,
projection: projectionTF,
count: this.count,
nPoints: universe.nObs,
count: annoMatrix.nObs,
nPoints: schema.dataframe.nObs,
minViewportDimension: Math.min(
viewport.width - globals.leftSidebarWidth || width,
viewport.height || height
@@ -396,8 +412,21 @@ class Scatterplot extends React.PureComponent {
}
render() {
const { dispatch } = this.props;
const { minimized } = this.state;
const {
dispatch,
annoMatrix,
scatterplotXXaccessor,
scatterplotYYaccessor,
colors,
crossfilter,
pointDilation,
} = this.props;
const { minimized, status, regl, viewport } = this.state;
if (status === "error") return null;
if (regl) {
this.renderCanvas();
}
return (
<div
@@ -459,10 +488,42 @@ class Scatterplot extends React.PureComponent {
marginLeft: margin.left,
marginTop: margin.top,
}}
ref={(canvas) => {
this.reglCanvas = canvas;
}}
ref={this.setReglCanvas}
/>
<Async
watchFn={Scatterplot.watchAsync}
promiseFn={this.fetchAsyncProps}
watchProps={{
annoMatrix,
scatterplotXXaccessor,
scatterplotYYaccessor,
colors,
crossfilter,
pointDilation,
viewport,
}}
>
<Async.Pending initial>Loading...</Async.Pending>
<Async.Rejected>{(error) => error.message}</Async.Rejected>
<Async.Fulfilled>
{(asyncProps) => {
if (regl && !shallowEqual(asyncProps, this.renderCache)) {
this.updateReglAndRender(asyncProps);
}
return (
<ScatterplotAxis
width={width}
height={height}
margin={margin}
scatterplotYYaccessor={scatterplotXXaccessor}
scatterplotXXaccessor={scatterplotYYaccessor}
xScale={asyncProps.xScale}
yScale={asyncProps.yScale}
/>
);
}}
</Async.Fulfilled>
</Async>
</div>
</div>
);
@@ -470,3 +531,75 @@ class Scatterplot extends React.PureComponent {
}
export default Scatterplot;
const ScatterplotAxis = React.memo(
({ scatterplotYYaccessor, scatterplotXXaccessor, xScale, yScale }) => {
/*
Axis for the scatterplot, rendered with SVG/D3. Props:
* scatterplotXXaccessor - name of X axis
* scatterplotXXaccessor - name of Y axis
* xScale - D3 scale for X axis (domain to range)
* yScale - D3 scale for Y axis (domain to range)
This also relies on the GLOBAL width/height/margin constants. If those become
become variables, may need to add the params.
*/
const svgRef = useRef(null);
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
// the axes are much cleaner and easier now. No need to rotate and orient
// the axis, just call axisBottom, axisLeft etc.
const xAxis = d3.axisBottom().ticks(7).scale(xScale);
const yAxis = d3.axisLeft().ticks(7).scale(yScale);
// adding axes is also simpler now, just translate x-axis to (0,height)
// and it's alread defined to be a bottom axis.
svg
.append("g")
.attr("transform", `translate(0,${height})`)
.attr("class", "x axis")
.call(xAxis);
// y-axis is translated to (0,0)
svg
.append("g")
.attr("transform", "translate(0,0)")
.attr("class", "y axis")
.call(yAxis);
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
svg
.append("text")
.attr("x", 10)
.attr("y", 10)
.attr("class", "label")
.style("font-style", "italic")
.text(scatterplotYYaccessor);
svg
.append("text")
.attr("x", width)
.attr("y", height - 10)
.attr("text-anchor", "end")
.attr("class", "label")
.style("font-style", "italic")
.text(scatterplotXXaccessor);
}, [scatterplotXXaccessor, scatterplotYYaccessor, xScale, yScale]);
return (
<svg
width={width + margin.left + margin.right}
height={height + margin.top + margin.bottom}
data-testid="scatterplot-svg"
>
<g ref={svgRef} transform={`translate(${margin.left},${margin.top})`} />
</svg>
);
}
);
@@ -1,26 +0,0 @@
// jshint esversion: 6
/*****************************************
******************************************
Setup SVG & Canvas elements
******************************************
******************************************/
import * as d3 from "d3";
const setupScatterplot = (width, height, margin) => {
const container = d3.select("#scatterplot");
const svg = container
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("data-testid", "scatterplot-svg")
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
return {
svg,
};
};
export default setupScatterplot;