mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-01 05:38:13 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user