- {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 (
);
@@ -57,18 +40,11 @@ class GeneExpression extends React.Component {
{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 (
{
- 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}
/>
+
+
+ Embedding loading...
+
+ {(error) => (
+
+ )}
+
+
+ {(asyncProps) => {
+ if (regl && !shallowEqual(asyncProps, this.cachedAsyncProps)) {
+ this.updateReglAndRender(asyncProps);
+ }
+ return null;
+ }}
+
+
);
}
}
+const ErrorLoading = ({ displayName, error, width, height }) => {
+ console.log(error); // log to console as this is an unepected error
+ return (
+
+ {`Failure loading ${displayName}`}
+
+ );
+};
+
export default Graph;
diff --git a/client/src/components/graph/overlays/centroidLabels.js b/client/src/components/graph/overlays/centroidLabels.js
index c83a2f8b..65f22215 100644
--- a/client/src/components/graph/overlays/centroidLabels.js
+++ b/client/src/components/graph/overlays/centroidLabels.js
@@ -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 (
+
+
+ {(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
+
+ );
+ });
- 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*/}
-
- 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}
-
-
- );
- });
-
- return <>{labelSVGS}>;
+ return <>{labelSVGS}>;
+ }}
+
+
+ );
}
}
+
+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 (
+
+ {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events --- the mouse actions for centroid labels do not have a screen reader alternative*/}
+ onMouseEnter(e, colorAccessor, label)}
+ onMouseOut={(e) => onMouseOut(e, colorAccessor, label)}
+ pointerEvents="visiblePainted"
+ >
+ {displayLabel}
+
+
+ );
+};
diff --git a/client/src/components/graph/overlays/graphOverlayLayer.js b/client/src/components/graph/overlays/graphOverlayLayer.js
index 751575d4..eb8d43bb 100644
--- a/client/src/components/graph/overlays/graphOverlayLayer.js
+++ b/client/src/components/graph/overlays/graphOverlayLayer.js
@@ -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,
})
);
diff --git a/client/src/components/graph/setupSVGandBrush.js b/client/src/components/graph/setupSVGandBrush.js
index 4351bdcf..d6a77bc4 100644
--- a/client/src/components/graph/setupSVGandBrush.js
+++ b/client/src/components/graph/setupSVGandBrush.js
@@ -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
diff --git a/client/src/components/menubar/cellSetButtons.js b/client/src/components/menubar/cellSetButtons.js
index d1d87412..26dba3f0 100644
--- a/client/src/components/menubar/cellSetButtons.js
+++ b/client/src/components/menubar/cellSetButtons.js
@@ -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({
diff --git a/client/src/components/menubar/diffexpButtons.js b/client/src/components/menubar/diffexpButtons.js
index 109cc4de..92d0c0a2 100644
--- a/client/src/components/menubar/diffexpButtons.js
+++ b/client/src/components/menubar/diffexpButtons.js
@@ -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,
diff --git a/client/src/components/menubar/embedding.js b/client/src/components/menubar/embedding.js
index 9760e01a..d0bfc260 100644
--- a/client/src/components/menubar/embedding.js
+++ b/client/src/components/menubar/embedding.js
@@ -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 {
/>
);
+*/
}
render() {
diff --git a/client/src/components/menubar/index.js b/client/src/components/menubar/index.js
index aff8ada6..0ce7ae24 100644
--- a/client/src/components/menubar/index.js
+++ b/client/src/components/menubar/index.js
@@ -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 :
}
diff --git a/client/src/components/miniHistogram/index.js b/client/src/components/miniHistogram/index.js
index fd30edc5..fb10e6cd 100644
--- a/client/src/components/miniHistogram/index.js
+++ b/client/src/components/miniHistogram/index.js
@@ -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);
diff --git a/client/src/components/miniStackedBar/index.js b/client/src/components/miniStackedBar/index.js
index ef4d92b3..bec086c9 100644
--- a/client/src/components/miniStackedBar/index.js
+++ b/client/src/components/miniStackedBar/index.js
@@ -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);
diff --git a/client/src/components/scatterplot/scatterplot.js b/client/src/components/scatterplot/scatterplot.js
index 8d209001..5a4be54f 100644
--- a/client/src/components/scatterplot/scatterplot.js
+++ b/client/src/components/scatterplot/scatterplot.js
@@ -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 (
{
- this.reglCanvas = canvas;
- }}
+ ref={this.setReglCanvas}
/>
+
+ Loading...
+ {(error) => error.message}
+
+ {(asyncProps) => {
+ if (regl && !shallowEqual(asyncProps, this.renderCache)) {
+ this.updateReglAndRender(asyncProps);
+ }
+ return (
+
+ );
+ }}
+
+
);
@@ -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 (
+
+ );
+ }
+);
diff --git a/client/src/components/scatterplot/setupScatterplot.js b/client/src/components/scatterplot/setupScatterplot.js
deleted file mode 100644
index 99a339a3..00000000
--- a/client/src/components/scatterplot/setupScatterplot.js
+++ /dev/null
@@ -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;
diff --git a/client/src/reducers/annoMatrix.js b/client/src/reducers/annoMatrix.js
new file mode 100644
index 00000000..830e1834
--- /dev/null
+++ b/client/src/reducers/annoMatrix.js
@@ -0,0 +1,12 @@
+/*
+Reducer for the annoMatrix
+*/
+
+const AnnoMatrix = (state = null, action) => {
+ if (action.annoMatrix) {
+ return action.annoMatrix;
+ }
+ return state;
+};
+
+export default AnnoMatrix;
diff --git a/client/src/reducers/autosave.js b/client/src/reducers/autosave.js
index b10039c1..929be152 100644
--- a/client/src/reducers/autosave.js
+++ b/client/src/reducers/autosave.js
@@ -2,22 +2,17 @@ const Autosave = (
state = {
saveInProgress: false,
error: false,
- lastSavedObsAnnotations: null,
- initialDataLoadComplete: false,
+ lastSavedAnnoMatrix: null,
},
- action,
- nextSharedState
+ action
) => {
switch (action.type) {
- case "initial data load complete (universe exists)": {
- /* don't save on init */
- const { universe } = nextSharedState;
+ case "annoMatrix: init complete": {
return {
...state,
error: false,
saveInProgress: false,
- lastSavedObsAnnotations: universe.obsAnnotations,
- initialDataLoadComplete: true,
+ lastSavedAnnoMatrix: action.annoMatrix,
};
}
@@ -29,21 +24,20 @@ const Autosave = (
}
case "writable obs annotations - save error": {
- const { message } = action;
return {
...state,
- error: message,
+ error: action.message,
saveInProgress: false,
};
}
case "writable obs annotations - save complete": {
- const lastSavedObsAnnotations = action.obsAnnotations;
+ const { lastSavedAnnoMatrix } = action;
return {
...state,
saveInProgress: false,
error: false,
- lastSavedObsAnnotations,
+ lastSavedAnnoMatrix,
};
}
diff --git a/client/src/reducers/categoricalSelection.js b/client/src/reducers/categoricalSelection.js
index 8a5f2972..d1080699 100644
--- a/client/src/reducers/categoricalSelection.js
+++ b/client/src/reducers/categoricalSelection.js
@@ -13,92 +13,28 @@ Label state default (if missing) is up to the component, but typically true.
*/
const CategoricalSelection = (state, action, nextSharedState) => {
switch (action.type) {
- case "initial data load complete (universe exists)":
- case "set World to current selection":
- case "reset World to eq Universe":
+ case "initial data load complete":
+ case "subset to selection":
+ case "reset subset":
case "set clip quantiles": {
- const { world } = nextSharedState;
+ const { annoMatrix } = nextSharedState;
const newState = CH.createCategoricalSelection(
- CH.selectableCategoryNames(world.schema)
+ CH.selectableCategoryNames(annoMatrix.schema)
);
return newState;
}
- case "universe: column load success": {
- const { dim } = action;
- if (dim !== "obsAnnotations") return state;
-
- const { dataframe } = action;
- const { world } = nextSharedState;
- const names = CH.selectableCategoryNames(
- world.schema,
- dataframe.colIndex.labels()
- );
- if (names.length === 0) return state;
+ case "categorical metadata filter select":
+ case "categorical metadata filter deselect":
+ case "categorical metadata filter none of these":
+ case "categorical metadata filter all of these": {
+ const { metadataField, labelSelectionState } = action;
return {
...state,
- ...CH.createCategoricalSelection(names),
+ [metadataField]: labelSelectionState,
};
}
- case "categorical metadata filter select": {
- /*
- Set the specific category in this field to false
- */
- const { metadataField, label } = action;
- const newSelected = new Map(state[metadataField]);
- newSelected.set(label, true);
- const newCategoricalSelection = {
- ...state,
- [action.metadataField]: newSelected,
- };
- return newCategoricalSelection;
- }
-
- case "categorical metadata filter deselect": {
- /*
- Set the specific category in this field to false
- */
- const { metadataField, label } = action;
- const newSelected = new Map(state[metadataField]);
- newSelected.set(label, false);
- const newCategoricalSelection = {
- ...state,
- [action.metadataField]: newSelected,
- };
- return newCategoricalSelection;
- }
-
- case "categorical metadata filter none of these": {
- /*
- set all categories in this field to false.
- */
- const { metadataField, labels } = action;
- const { selected } = state[metadataField];
- const newSelected = new Map(selected);
- labels.forEach((label) => newSelected.set(label, false));
- const newCategoricalSelection = {
- ...state,
- [action.metadataField]: newSelected,
- };
- return newCategoricalSelection;
- }
-
- case "categorical metadata filter all of these": {
- /*
- set all categories in this field to true.
- */
- const { metadataField, labels } = action;
- const { selected } = state[metadataField];
- const newSelected = new Map(selected);
- labels.forEach((label) => newSelected.set(label, true));
- const newCategoricalSelection = {
- ...state,
- [action.metadataField]: newSelected,
- };
- return newCategoricalSelection;
- }
-
case "annotation: create category": {
const name = action.data;
return {
diff --git a/client/src/reducers/centroidLabels.js b/client/src/reducers/centroidLabels.js
index 624177b1..6481ec1d 100644
--- a/client/src/reducers/centroidLabels.js
+++ b/client/src/reducers/centroidLabels.js
@@ -1,61 +1,24 @@
-import calcCentroid from "../util/centroid";
-
const initialState = {
- labels: [],
showLabels: false,
};
const centroidLabels = (state = initialState, action, sharedNextState) => {
const {
- world,
- layoutChoice,
- categoricalSelection,
colors: { colorAccessor },
} = sharedNextState;
const showLabels = action.showLabels ?? state.showLabels;
switch (action.type) {
- case "annotation: label current cell selection":
- case "annotation: label edited":
- case "annotation: delete label":
- case "set layout choice":
- case "set World to current selection":
- case "reset World to eq Universe":
- return {
- ...state,
- labels:
- !!colorAccessor && showLabels && !!categoricalSelection[colorAccessor]
- ? calcCentroid(world, colorAccessor, layoutChoice.currentDimNames)
- : [],
- };
-
case "color by categorical metadata":
case "show centroid labels for category":
// If colorby is not enabled or labels are not toggled to show
// then clear the labels and make sure the toggle is off
- if (!colorAccessor || !showLabels) {
- return {
- ...state,
- labels: [],
- showLabels,
- };
- }
-
return {
...state,
- labels: calcCentroid(
- world,
- colorAccessor,
- layoutChoice.currentDimNames
- ),
- showLabels,
+ showLabels: colorAccessor && showLabels,
};
- case "color by continuous metadata":
- case "color by expression":
- return { ...state, labels: [] };
-
case "reset centroid labels":
return initialState;
diff --git a/client/src/reducers/colors.js b/client/src/reducers/colors.js
index a7c566fe..9cce683a 100644
--- a/client/src/reducers/colors.js
+++ b/client/src/reducers/colors.js
@@ -1,32 +1,17 @@
-import { ColorHelpers } from "../util/stateManager";
+/*
+Color By UI state
+*/
const ColorsReducer = (
state = {
colorMode: null,
colorAccessor: null,
- rgb: null,
- scale: null,
},
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
- case "universe exists, but loading is still in progress": {
- /* initialize everything with default colors, no mode, no color-by accessor */
- const { world } = nextSharedState;
- const colorMode = null;
- const colorAccessor = null;
- const { rgb, scale } = ColorHelpers.createColors(world);
- return {
- ...state,
- colorAccessor,
- colorMode,
- rgb,
- scale,
- };
- }
-
case "universe: user color load success": {
const { userColors } = action;
return {
@@ -35,46 +20,17 @@ const ColorsReducer = (
};
}
- case "reset World to eq Universe": {
- /* need to rebuild colors as world may have changed, but don't switch modes */
- const { world } = nextSharedState;
- const { colorMode, colorAccessor } = state;
- const { rgb, scale } = ColorHelpers.createColors(
- world,
- colorMode,
- colorAccessor
- );
- return {
- ...state,
- rgb,
- scale,
- };
- }
-
+ case "clear differential expression":
case "set clip quantiles":
- case "set World to current selection": {
- const { world: prevWorld, controls: prevControls } = prevSharedState;
- const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
- prevControls,
- state,
- prevWorld
- );
- if (resetColorState) {
- return resetColorState;
+ case "subset to selection": {
+ const { controls: prevControls } = prevSharedState;
+ if (prevControls.diffexpGenes.includes(state.colorAccessor)) {
+ return {
+ colorMode: null,
+ colorAccessor: null,
+ };
}
-
- const { colorMode, colorAccessor } = state;
- const { world } = nextSharedState;
- const { rgb, scale } = ColorHelpers.createColors(
- world,
- colorMode,
- colorAccessor
- );
- return {
- ...state,
- rgb,
- scale,
- };
+ return state;
}
case "annotation: delete category": {
@@ -85,21 +41,21 @@ const ColorsReducer = (
/* else reset */
return {
...state,
- ...ColorHelpers.resetColors(prevSharedState.world),
+ colorMode: null,
+ colorAccessor: null,
};
}
case "reset colorscale": {
return {
...state,
- ...ColorHelpers.resetColors(prevSharedState.world),
+ colorMode: null,
+ colorAccessor: null,
};
}
case "color by categorical metadata":
case "color by continuous metadata": {
- const { world, colors } = prevSharedState;
-
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode &&
@@ -107,78 +63,27 @@ const ColorsReducer = (
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.colorAccessor : null;
- const { rgb, scale } = ColorHelpers.createColors(
- world,
- colorMode,
- colorAccessor,
- colors.userColors
- );
return {
...state,
colorMode,
colorAccessor,
- rgb,
- scale,
};
}
case "color by expression": {
- const { world } = prevSharedState;
-
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode && action.gene === state.colorAccessor;
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.gene : null;
- const { rgb, scale } = ColorHelpers.createColors(
- world,
- colorMode,
- colorAccessor
- );
return {
...state,
colorMode,
colorAccessor,
- rgb,
- scale,
};
}
- case "annotation: add new label to category":
- case "annotation: label current cell selection":
- case "annotation: delete label": {
- const { world } = nextSharedState;
- const { colorMode, colorAccessor } = state;
- const { metadataField } = action;
- if (
- colorMode !== "color by categorical metadata" ||
- colorAccessor !== metadataField
- )
- return state;
-
- /* else, we need to rebuild colors as labels have changed! */
- const { rgb, scale } = ColorHelpers.createColors(
- world,
- colorMode,
- colorAccessor
- );
- return { ...state, rgb, scale };
- }
-
- case "clear differential expression": {
- const { world: prevWorld, controls: prevControls } = prevSharedState;
- const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
- prevControls,
- state,
- prevWorld
- );
- if (resetColorState) {
- return resetColorState;
- }
- return state;
- }
-
default: {
return state;
}
diff --git a/client/src/reducers/continuousSelection.js b/client/src/reducers/continuousSelection.js
index 98884983..6f823ca5 100644
--- a/client/src/reducers/continuousSelection.js
+++ b/client/src/reducers/continuousSelection.js
@@ -2,7 +2,7 @@ import { makeContinuousDimensionName } from "../util/nameCreators";
const ContinuousSelection = (state = {}, action) => {
switch (action.type) {
- case "reset World to eq Universe":
+ case "reset subset":
case "set clip quantiles": {
return {};
}
diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js
index cdec252f..f8bfc37f 100644
--- a/client/src/reducers/controls.js
+++ b/client/src/reducers/controls.js
@@ -21,9 +21,7 @@ const Controls = (
scatterplotYYaccessor: null,
graphRenderCounter: 0 /* integer as