Files
cellxgene/client/src/components/graph/graph.js
Severiano Badajoz 8d725b1ad9 centroid labels (#872)
* refactor reducer to no longer support hover state and hold many labels

* refactor to generate centroidCoordinates for all values of a category

* create hash for function and memoize export

* create button to display all labels for a category

* clear state

* create label for each thing

* calculate on each value

* change to in place modification of map

* switch to for loop with iterator instead of forEach

* use map from centroidLabel instead of creating copy

* adapt for map

* utilize tarrays

* begin documentation

* disable centroids if in zoom mode

* clean up

* persist uncalc coordinates

* document

* clean up and document

* cleanup and document

* fix

* fix undefined labels and document changes

* fix first element skip

* fix conditional recalc

* rename centroidLabel -> centroidLabels

* break out dilation on hover to new reducer

* numerous styling changes for readability

* change centroid icon

* remove colorAccessor from parameters

* recalc centroids on world change

* make label toggle undoable

* remove unused import

* highlight labels on hover

* remove special characters from svg id

* lighten backdrop

* only generate new centroids if they pre-exist

* fix issue with spaces in catagorical value name

* add label buttons to menubar

* change reducer to use colorAccessor and have single toggle

* fix check to see if svg should be rendered

* move svg overlays onto a single svg layer

* dilate on label hover

* remove logs

* allow centroid to update along side regl renders

* allow actions to pass through svg if in zoom mode

* remove artifact from circle

* remove comment

* remove disabling of centroid button

* fix conditional map to screen

* make styling label conditions stricter

* prettier

* refactor onto master

* refactor computePointFlags() to use pointDilation store

* notify when viewport changes

* move svg attributes out of lasso setup and prevent rerenders/writes

* begin playing with transform matrix

* first solution for camera interaction

* create transform using nested groups

* semi-working method using nested groups with transforms

* inversely scale text

* properly do final transform

* cleanup dead / test code

* reinstate original functionality

* breakout centroid labels labels into separate component

* default toggle on for testing

* separate lasso and centroid layers

* remove unnecessary attributes, working hover

* dilation on label hover

* fix dilation on scatterplot

* add dilation on label hover

* break overlay into separate component

* make overlay agnostic to children

* move label mouse actions to centroidlabels component, add overlay state

* remove lasso on switch to camera

* disallow user selection

* fix reducer

* fix subset with continuous color error

* reset labels on color by continuous

* revert centroids on by default

* refactor for nested restructuring

* remove update checking

* remove unused method

* readd deleted hover delay

* remove old centroid setup

* remove centroid from undoable

* cleanup dead code

* remove dead code

* rollback unnecessary changes

* begin adding annotation functionality

* add annotation functionality

* add reset and undo functionality

* change centroids on layout change

* don't create label for unassigned

* add comment pointing out POI for performance

* touch up matrix transform comment

* add comment explaining coordinate space and children's assumed space

* remove dead code

* switch to pure component

* connect centroidLabels to redux

* clean up camera check and null result

* tool tip change

* rename centroid toggle and the like

* fix the misalignment of buttons, also make blueprint use consistent

* fix comment spelling mistakes

* introduce variable for cleaner logic expressions and state assignment

* alter tooltip text to back color by interaction

* remove manual iterator manipulation for forEach()

* remove debounce

* nit fix

* tooltip wording fix

* lint
2020-01-22 13:40:22 -08:00

785 lines
22 KiB
JavaScript

// jshint esversion: 6
import React from "react";
import * as d3 from "d3";
import { connect } from "react-redux";
import { mat3, vec2 } from "gl-matrix";
import _regl from "regl";
import memoize from "memoize-one";
import * as globals from "../../globals";
import setupSVGandBrushElements from "./setupSVGandBrush";
import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl";
import { isTypedArray } from "../../util/typeHelpers";
import styles from "./graph.css";
import GraphOverlayLayer from "./overlays/graphOverlayLayer";
import CentroidLabels from "./overlays/centroidLabels";
/*
Simple 2D transforms control all point painting. There are three:
* model - convert from underlying per-point coordinate to a layout.
Currently used to move from data to webgl coordinate system.
* camera - apply a 2D camera transformation (pan, zoom)
* projection - apply any transformation required for screen size and layout
*/
function createProjectionTF(viewportWidth, viewportHeight) {
/*
the projection transform accounts for the screen size & other layout
*/
const fractionToUse = 0.95; // fraction of min dimension to use
const topGutterSizePx = 32; // toolbar box height
const heightMinusGutter = viewportHeight - topGutterSizePx;
const minDim = Math.min(viewportWidth, heightMinusGutter);
const aspectScale = [
(fractionToUse * minDim) / viewportWidth,
(fractionToUse * minDim) / viewportHeight
];
const m = mat3.create();
mat3.fromTranslation(m, [
0,
-topGutterSizePx / viewportHeight / aspectScale[1]
]);
mat3.scale(m, m, aspectScale);
return m;
}
function createModelTF() {
/*
preallocate coordinate system transformation between data and gl.
Data arrives in a [0,1] range, and we operate elsewhere in [-1,1].
*/
const m = mat3.fromScaling(mat3.create(), [2, 2]);
mat3.translate(m, m, [-0.5, -0.5]);
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;
});
};
}
@connect(state => ({
universe: state.universe,
world: state.world,
crossfilter: state.crossfilter,
responsive: state.responsive,
colorRGB: state.colors.rgb,
selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection,
layoutChoice: state.layoutChoice,
centroidLabels: state.centroidLabels,
graphInteractionMode: state.controls.graphInteractionMode,
colorAccessor: state.colors.colorAccessor,
pointDilation: state.pointDilation
}))
class Graph extends React.PureComponent {
computePointPositions = memoize((X, Y, modelTF) => {
/*
compute the model coordinate for each point
*/
const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) {
const p = vec2.fromValues(X[i], Y[i]);
vec2.transformMat3(p, p, modelTF);
positions[2 * i] = p[0];
positions[2 * i + 1] = p[1];
}
return positions;
});
computePointColors = memoize(rgb => {
/*
compute webgl colors for each point
*/
const colors = new Float32Array(3 * rgb.length);
for (let i = 0, len = rgb.length; i < len; i += 1) {
colors.set(rgb[i], 3 * i);
}
return colors;
});
computeSelectedFlags = memoize(
(crossfilter, flagSelected, flagUnselected) => {
const x = crossfilter.fillByIsSelected(
new Float32Array(crossfilter.size()),
flagSelected,
flagUnselected
);
return x;
}
);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, pointDilation) => {
/*
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 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) {
for (let i = 0, len = flags.length; i < len; i += 1) {
if (highlightData) {
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
}
if (colorByData) {
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
}
}
}
return flags;
}
);
constructor(props) {
super(props);
this.count = 0;
this.graphPaddingTop = 0;
this.graphPaddingRightLeft = globals.leftSidebarWidth * 2;
this.renderCache = {
X: null,
Y: null,
positions: null,
colors: null,
sizes: null,
flags: null
};
this.state = {
toolSVG: null,
tool: null,
container: null,
cameraRender: 0
};
}
componentDidMount() {
// 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
);
this.setState({
regl,
drawPoints,
pointBuffer,
colorBuffer,
flagBuffer,
camera,
modelTF,
modelInvTF: mat3.invert([], modelTF),
projectionTF
});
}
componentDidUpdate(prevProps) {
const { renderCache } = this;
const {
world,
crossfilter,
colorRGB,
responsive,
selectionTool,
currentSelection,
layoutChoice,
graphInteractionMode,
pointDilation,
colorAccessor
} = this.props;
const { regl, toolSVG, camera, modelTF } = this.state;
let stateChanges = {};
if (regl && world) {
/* update the regl and point rendering state */
const { obsLayout, nObs } = world;
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
let { projectionTF } = this.state;
let needsRepaint = false;
if (
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {
projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
needsRepaint = true;
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 (
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {
// If the window size has changed we want to recreate all SVGs
stateChanges = {
...stateChanges,
...this.createToolSVG()
};
} else if (
(responsive.height && responsive.width && !toolSVG) ||
selectionTool !== prevProps.selectionTool
) {
// first time or change of selection tool
stateChanges = { ...stateChanges, ...this.createToolSVG(true) };
} else if (prevProps.graphInteractionMode !== graphInteractionMode) {
// If lasso/zoom is switched
stateChanges = {
...stateChanges,
...this.createToolSVG()
};
}
/*
if the selection tool or state has changed, ensure that the selection
tool correctly reflects the underlying selection.
*/
if (
currentSelection !== prevProps.currentSelection ||
graphInteractionMode !== prevProps.graphInteractionMode ||
stateChanges.toolSVG
) {
const { tool, container } = this.state;
this.selectionToolUpdate(
stateChanges.tool ? stateChanges.tool : tool,
stateChanges.container ? stateChanges.container : container
);
}
if (Object.keys(stateChanges).length > 0) {
this.setState(stateChanges);
}
}
handleCanvasEvent = e => {
const { camera, projectionTF } = this.state;
if (e.type !== "wheel") e.preventDefault();
if (camera.handleEvent(e, projectionTF)) {
this.renderCanvas();
this.setState(state => {
return { ...state, updateOverlay: !state.updateOverlay };
});
}
};
createToolSVG = () => {
/*
Called from componentDidUpdate. Create the tool SVG, and return any
state changes that should be passed to setState().
*/
const { responsive, selectionTool, graphInteractionMode } = this.props;
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#lasso-layer")
.selectAll(".lasso-group")
.remove();
// Don't render or recreate toolSVG if currently in zoom mode
if (graphInteractionMode !== "select") return { toolSVG: undefined };
let handleStart;
let handleDrag;
let handleEnd;
let handleCancel;
if (selectionTool === "brush") {
handleStart = this.handleBrushStartAction.bind(this);
handleDrag = this.handleBrushDragAction.bind(this);
handleEnd = this.handleBrushEndAction.bind(this);
} else {
handleStart = this.handleLassoStart.bind(this);
handleEnd = this.handleLassoEnd.bind(this);
handleCancel = this.handleLassoCancel.bind(this);
}
const { svg: newToolSVG, tool, container } = setupSVGandBrushElements(
selectionTool,
handleStart,
handleDrag,
handleEnd,
handleCancel,
responsive,
this.graphPaddingRightLeft,
graphInteractionMode
);
return { toolSVG: newToolSVG, tool, container };
};
brushToolUpdate(tool, container) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { currentSelection } = this.props;
if (container) {
const toolCurrentSelection = d3.brushSelection(container.node());
if (currentSelection.mode === "within-rect") {
/*
if there is a selection, make sure the brush tool matches
*/
const screenCoords = [
this.mapPointToScreen(currentSelection.brushCoords.northwest),
this.mapPointToScreen(currentSelection.brushCoords.southeast)
];
if (!toolCurrentSelection) {
/* tool is not selected, so just move the brush */
container.call(tool.move, screenCoords);
} else {
/* there is an active selection and a brush - make sure they match */
/* this just sums the difference of each dimension, of each point */
let delta = 0;
for (let x = 0; x < 2; x += 1) {
for (let y = 0; y < 2; y += 1) {
delta += Math.abs(
screenCoords[x][y] - toolCurrentSelection[x][y]
);
}
}
if (delta > 0) {
container.call(tool.move, screenCoords);
}
}
} else if (toolCurrentSelection) {
/* no selection, so clear the brush tool if it is set */
container.call(tool.move, null);
}
}
}
lassoToolUpdate(tool) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { currentSelection } = this.props;
if (currentSelection.mode === "within-polygon") {
/*
if there is a current selection, make sure the lasso tool matches
*/
const polygon = currentSelection.polygon.map(p =>
this.mapPointToScreen(p)
);
tool.move(polygon);
} else {
tool.reset();
}
}
selectionToolUpdate(tool, container) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { selectionTool } = this.props;
switch (selectionTool) {
case "brush":
this.brushToolUpdate(tool, container);
break;
case "lasso":
this.lassoToolUpdate(tool, container);
break;
default:
/* punt? */
break;
}
}
mapScreenToPoint(pin) {
/*
Map an XY coordinates from screen domain to cell/point range,
accounting for current pan/zoom camera.
*/
const { responsive } = this.props;
const { camera, projectionTF, modelInvTF } = this.state;
const cameraInvTF = camera.invView();
/* screen -> gl */
const x =
(2 * pin[0]) / (responsive.width - this.graphPaddingRightLeft) - 1;
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const xy = vec2.fromValues(x, y);
const projectionInvTF = mat3.invert(mat3.create(), projectionTF);
vec2.transformMat3(xy, xy, projectionInvTF);
vec2.transformMat3(xy, xy, cameraInvTF);
vec2.transformMat3(xy, xy, modelInvTF);
return xy;
}
mapPointToScreen(xyCell) {
/*
Map an XY coordinate from cell/point domain to screen range. Inverse
of mapScreenToPoint()
*/
const { responsive } = this.props;
const { camera, projectionTF, modelTF } = this.state;
const cameraTF = camera.view();
const xy = vec2.transformMat3(vec2.create(), xyCell, modelTF);
vec2.transformMat3(xy, xy, cameraTF);
vec2.transformMat3(xy, xy, projectionTF);
const pin = [
Math.round(
((xy[0] + 1) * (responsive.width - this.graphPaddingRightLeft)) / 2
),
Math.round(
-((xy[1] + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
)
];
return pin;
}
handleBrushDragAction() {
/*
event describing brush position:
@-------|
| |
| |
|-------@
*/
// ignore programatically generated events
if (d3.event.sourceEvent === null || !d3.event.selection) return;
const { dispatch } = 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
});
}
handleBrushStartAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
const { dispatch } = this.props;
dispatch({ type: "graph brush start" });
}
handleBrushEndAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
/*
coordinates will be included if selection made, null
if selection cleared.
*/
const { dispatch } = 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
});
} else {
dispatch({
type: "graph brush deselect"
});
}
}
handleBrushDeselectAction() {
const { dispatch } = this.props;
dispatch({
type: "graph brush deselect"
});
}
handleLassoStart() {
const { dispatch } = this.props;
dispatch({
type: "graph lasso start"
});
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const minimumPolygonArea = 10;
const { dispatch } = 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" });
} else {
dispatch({
type: "graph lasso end",
polygon: polygon.map(xy => this.mapScreenToPoint(xy)) // transform the polygon
});
}
}
handleLassoCancel() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso cancel" });
}
handleLassoDeselectAction() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso deselect" });
}
handleDeselectAction() {
const { selectionTool } = this.props;
if (selectionTool === "brush") this.handleBrushDeselectAction();
if (selectionTool === "lasso") this.handleLassoDeselectAction();
}
handleOpacityRangeChange(e) {
const { dispatch } = this.props;
dispatch({
type: "change opacity deselected cells in 2d graph background",
data: e.target.value
});
}
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 || 800, height || 600)
});
regl._gl.flush();
}
renderCanvas = renderThrottle(() => {
const {
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
} = this.state;
this.renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
);
});
render() {
const { responsive, graphInteractionMode } = this.props;
const { modelTF, projectionTF, camera } = this.state;
const cameraTF = camera?.view();
return (
<div id="graphWrapper">
<div
style={{
zIndex: -9999,
position: "fixed",
top: this.graphPaddingTop,
right: globals.leftSidebarWidth
}}
>
<div id="graphAttachPoint">
<GraphOverlayLayer
cameraTF={cameraTF}
modelTF={modelTF}
projectionTF={projectionTF}
graphPaddingRightLeft={this.graphPaddingRightLeft}
graphPaddingTop={this.graphPaddingTop}
responsive={responsive}
>
<CentroidLabels />
</GraphOverlayLayer>
<svg
id="lasso-layer"
data-testid="layout-overlay"
className={styles.graphSVG}
width={responsive.width - this.graphPaddingRightLeft}
height={responsive.height}
pointerEvents={
graphInteractionMode === "select" ? "auto" : "none"
}
style={{ zIndex: 89 }}
/>
</div>
<div style={{ padding: 0, margin: 0 }}>
<canvas
width={responsive.width - this.graphPaddingRightLeft}
height={responsive.height - this.graphPaddingTop}
data-testid="layout-graph"
ref={canvas => {
this.reglCanvas = canvas;
}}
onMouseDown={this.handleCanvasEvent}
onMouseUp={this.handleCanvasEvent}
onMouseMove={this.handleCanvasEvent}
onDoubleClick={this.handleCanvasEvent}
onWheel={this.handleCanvasEvent}
/>
</div>
</div>
</div>
);
}
}
export default Graph;