mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-30 21:18:12 +08:00
Graph/scatterplot improvements (#893)
* many graph/scatterplot fixes: 722, 882, 758, 886 * fix e2e test breakage caused by graph transform work * convert webgl point shaders to use flags rather than explicit point size * remove unused packages from dependencies * fix typo in regl prop name * factor common code into util helper file * legibility tweaks
This commit is contained in:
@@ -1,58 +1,64 @@
|
||||
// jshint esversion: 6
|
||||
const mat4 = require("gl-mat4");
|
||||
const vec3 = require("gl-vec3");
|
||||
|
||||
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
|
||||
import { glPointFlags } from "../../util/glHelpers";
|
||||
|
||||
export default function(regl) {
|
||||
return regl({
|
||||
vert: `
|
||||
precision mediump float;
|
||||
|
||||
attribute vec2 position;
|
||||
attribute vec3 color;
|
||||
attribute float size;
|
||||
attribute float flag;
|
||||
|
||||
uniform float distance;
|
||||
uniform mat4 projection, view;
|
||||
varying vec3 fragColor;
|
||||
uniform mat3 projView;
|
||||
uniform float nPoints;
|
||||
uniform float minViewportDimension;
|
||||
|
||||
varying vec4 fragColor;
|
||||
|
||||
const float zBottom = 0.99;
|
||||
const float zMiddle = 0.;
|
||||
const float zTop = -1.;
|
||||
|
||||
// import getFlags()
|
||||
${glPointFlags}
|
||||
|
||||
void main() {
|
||||
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
|
||||
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
|
||||
fragColor = color;
|
||||
bool isNaN, isSelected, isHighlight;
|
||||
getFlags(flag, isNaN, isSelected, isHighlight);
|
||||
|
||||
float size = isHighlight ? 8. : isSelected ? 4. : 1.;
|
||||
gl_PointSize = (0.5 * pow(distance, 2.5)) + size;
|
||||
|
||||
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
|
||||
vec3 xy = projView * vec3(position, 1.);
|
||||
gl_Position = vec4(xy.xy, z, 1.);
|
||||
|
||||
float alpha = isNaN ? 0.9 : 1.0;
|
||||
fragColor = vec4(color, alpha);
|
||||
}`,
|
||||
|
||||
frag: `
|
||||
precision mediump float;
|
||||
varying vec3 fragColor;
|
||||
varying vec4 fragColor;
|
||||
void main() {
|
||||
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = vec4(fragColor, 1);
|
||||
gl_FragColor = fragColor;
|
||||
}`,
|
||||
|
||||
attributes: {
|
||||
position: regl.prop("position"),
|
||||
color: regl.prop("color"),
|
||||
size: regl.prop("size")
|
||||
flag: regl.prop("flag")
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
distance: regl.prop("distance"),
|
||||
view: regl.prop("view"),
|
||||
projection: ({ viewportWidth, viewportHeight }) => {
|
||||
const aspectRatio = viewportWidth / viewportHeight;
|
||||
let m = mat4.perspective(
|
||||
[],
|
||||
Math.PI / 2,
|
||||
viewportWidth / viewportHeight,
|
||||
0.01,
|
||||
1000
|
||||
);
|
||||
if (aspectRatio < 1) {
|
||||
m = mat4.scale(m, m, vec3.fromValues(1, 1, 1 / aspectRatio));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
projView: regl.prop("projView"),
|
||||
nPoints: regl.prop("nPoints"),
|
||||
minViewportDimension: regl.prop("minViewportDimension")
|
||||
},
|
||||
|
||||
count: regl.prop("count"),
|
||||
|
||||
@@ -2,21 +2,70 @@
|
||||
import React from "react";
|
||||
import * as d3 from "d3";
|
||||
import { connect } from "react-redux";
|
||||
import mat4 from "gl-mat4";
|
||||
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 setupCentroidSVG from "./setupCentroidSVG";
|
||||
import actions from "../../actions";
|
||||
import _camera from "../../util/camera";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import scaleLinear from "../../util/scaleLinear";
|
||||
import { isTypedArray } from "../../util/typeHelpers";
|
||||
|
||||
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
|
||||
/*
|
||||
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) {
|
||||
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,
|
||||
@@ -29,14 +78,16 @@ import scaleLinear from "../../util/scaleLinear";
|
||||
colorAccessor: state.colors.colorAccessor
|
||||
}))
|
||||
class Graph extends React.Component {
|
||||
computePointPositions = memoize((X, Y, scaleX, scaleY) => {
|
||||
computePointPositions = memoize((X, Y, modelTF) => {
|
||||
/*
|
||||
compute webgl coordinate buffer for each point
|
||||
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) {
|
||||
positions[2 * i] = scaleX(X[i]);
|
||||
positions[2 * i + 1] = scaleY(Y[i]);
|
||||
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;
|
||||
});
|
||||
@@ -52,38 +103,65 @@ class Graph extends React.Component {
|
||||
return colors;
|
||||
});
|
||||
|
||||
computePointSizesFromCrossfilter = memoize((len, crossfilter) => {
|
||||
const sizes = new Float32Array(len);
|
||||
crossfilter.fillByIsSelected(sizes, 4, 0.2);
|
||||
|
||||
return sizes;
|
||||
});
|
||||
|
||||
computePointSizes = memoize(
|
||||
(len, crossfilter, metadataField, categoryField) => {
|
||||
/*
|
||||
compute webgl dot size for each point
|
||||
*/
|
||||
|
||||
const selectionSizes = this.computePointSizesFromCrossfilter(
|
||||
len,
|
||||
crossfilter
|
||||
computeSelectedFlags = memoize(
|
||||
(crossfilter, flagSelected, flagUnselected) => {
|
||||
const x = crossfilter.fillByIsSelected(
|
||||
new Float32Array(crossfilter.size()),
|
||||
flagSelected,
|
||||
flagUnselected
|
||||
);
|
||||
let sizes;
|
||||
return x;
|
||||
}
|
||||
);
|
||||
|
||||
if (metadataField && categoryField) {
|
||||
sizes = selectionSizes.slice();
|
||||
const valuesArr = crossfilter.data.col(metadataField).asArray();
|
||||
computePointFlags = memoize(
|
||||
(world, crossfilter, colorAccessor, centroidLabel) => {
|
||||
/*
|
||||
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)
|
||||
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (valuesArr[i] === categoryField) {
|
||||
sizes[i] = 10;
|
||||
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 } = centroidLabel;
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sizes = selectionSizes;
|
||||
}
|
||||
return sizes;
|
||||
return flags;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -97,7 +175,8 @@ class Graph extends React.Component {
|
||||
Y: null,
|
||||
positions: null,
|
||||
colors: null,
|
||||
sizes: null
|
||||
sizes: null,
|
||||
flags: null
|
||||
};
|
||||
this.state = {
|
||||
toolSVG: null,
|
||||
@@ -108,55 +187,48 @@ class Graph extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// setup canvas and camera
|
||||
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
|
||||
// setup canvas, webgl draw function and camera
|
||||
const camera = _camera(this.reglCanvas, {
|
||||
pan: true,
|
||||
scale: true,
|
||||
rotate: false
|
||||
});
|
||||
const regl = _regl(this.reglCanvas);
|
||||
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// preallocate buffers
|
||||
// preallocate webgl buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const sizeBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
// preallocate coordinate system transformation between data and gl
|
||||
const fractionToUse = 0.93; // fraction of dimension to use
|
||||
const shiftForMenuBar = 0.05;
|
||||
const transform = {
|
||||
glScaleX: scaleLinear([0, 1], [-1 * fractionToUse, 1 * fractionToUse]),
|
||||
glScaleY: scaleLinear(
|
||||
[0, 1],
|
||||
[
|
||||
(1 + shiftForMenuBar) * fractionToUse,
|
||||
(-1 + shiftForMenuBar) * fractionToUse
|
||||
]
|
||||
)
|
||||
};
|
||||
// create all default rendering transformations
|
||||
const modelTF = createModelTF();
|
||||
const projectionTF = createProjectionTF(
|
||||
this.reglCanvas.width,
|
||||
this.reglCanvas.height
|
||||
);
|
||||
|
||||
/* first time, but this duplicates above function, should be possile to avoid this */
|
||||
const reglRender = regl.frame(() => {
|
||||
this.reglDraw(
|
||||
regl,
|
||||
drawPoints,
|
||||
sizeBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
camera
|
||||
);
|
||||
camera.tick();
|
||||
});
|
||||
|
||||
this.reglRenderState = "rendering";
|
||||
// initial draw to canvas
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
);
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
drawPoints,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
sizeBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
reglRender,
|
||||
transform
|
||||
modelTF,
|
||||
modelInvTF: mat3.invert([], modelTF),
|
||||
projectionTF
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,50 +246,47 @@ class Graph extends React.Component {
|
||||
colorAccessor,
|
||||
centroidLabel
|
||||
} = this.props;
|
||||
const { reglRender, mode, regl, toolSVG, centroidSVG } = this.state;
|
||||
const { regl, toolSVG, centroidSVG } = this.state;
|
||||
let stateChanges = {};
|
||||
|
||||
if (reglRender) {
|
||||
if (
|
||||
// If it IS RENDERING and it is NOT IN ZOOM mode, stop rendering.
|
||||
this.reglRenderState === "rendering" &&
|
||||
graphInteractionMode !== "zoom"
|
||||
) {
|
||||
reglRender.cancel();
|
||||
this.reglRenderState = "paused";
|
||||
}
|
||||
|
||||
if (
|
||||
// If it is NOT RENDERING and it IS IN ZOOM mode, start rendering
|
||||
this.reglRenderState !== "rendering" &&
|
||||
graphInteractionMode === "zoom"
|
||||
) {
|
||||
this.restartReglLoop();
|
||||
this.reglRenderState = "rendering";
|
||||
}
|
||||
}
|
||||
|
||||
if (regl && world) {
|
||||
/* update the regl state */
|
||||
/* update the regl and point rendering state */
|
||||
const { obsLayout, nObs } = world;
|
||||
const {
|
||||
drawPoints,
|
||||
transform,
|
||||
camera,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
sizeBuffer
|
||||
flagBuffer,
|
||||
modelTF
|
||||
} = 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 { glScaleX, glScaleY } = transform;
|
||||
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
|
||||
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
|
||||
const newPositions = this.computePointPositions(X, Y, glScaleX, glScaleY);
|
||||
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 */
|
||||
@@ -226,33 +295,35 @@ class Graph extends React.Component {
|
||||
/* update our cache & GL if the buffer changes */
|
||||
renderCache.colors = newColors;
|
||||
colorBuffer({ data: newColors, dimension: 3 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
/* sizes for each point */
|
||||
const { metadataField, categoryField } = centroidLabel;
|
||||
const newSizes = this.computePointSizes(
|
||||
nObs,
|
||||
/* flags for each point */
|
||||
const newFlags = this.computePointFlags(
|
||||
world,
|
||||
crossfilter,
|
||||
metadataField,
|
||||
categoryField
|
||||
colorAccessor,
|
||||
centroidLabel
|
||||
);
|
||||
if (renderCache.sizes !== newSizes) {
|
||||
/* update our cache & GL if the buffer changes */
|
||||
renderCache.size = newSizes;
|
||||
sizeBuffer({ data: newSizes, dimension: 1 });
|
||||
if (renderCache.flags !== newFlags) {
|
||||
renderCache.flags = newFlags;
|
||||
flagBuffer({ data: newFlags, dimension: 1 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
this.count = nObs;
|
||||
|
||||
regl._refresh();
|
||||
this.reglDraw(
|
||||
regl,
|
||||
drawPoints,
|
||||
sizeBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
camera
|
||||
);
|
||||
if (needsRepaint) {
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const createToolSVG = () => {
|
||||
@@ -311,7 +382,8 @@ class Graph extends React.Component {
|
||||
stateChanges = { ...stateChanges, centroidSVG: newCentroidSVG };
|
||||
};
|
||||
|
||||
// Centroid SVG creation is disabled for now but should go into the first and third cases if enabled
|
||||
// Centroid SVG creation is disabled for now but should go into the
|
||||
// first and third cases if enabled
|
||||
if (
|
||||
prevProps.responsive.height !== responsive.height ||
|
||||
prevProps.responsive.width !== responsive.width
|
||||
@@ -353,6 +425,14 @@ class Graph extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleCanvasEvent = e => {
|
||||
const { camera, projectionTF } = this.state;
|
||||
if (e.type !== "wheel") e.preventDefault();
|
||||
if (camera.handleEvent(e, projectionTF)) {
|
||||
this.renderCanvas();
|
||||
}
|
||||
};
|
||||
|
||||
brushToolUpdate(tool, container) {
|
||||
/*
|
||||
this is called from componentDidUpdate(), so be very careful using
|
||||
@@ -433,76 +513,25 @@ class Graph extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1]
|
||||
});
|
||||
drawPoints({
|
||||
size: sizeBuffer,
|
||||
distance: camera.distance,
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
count: this.count,
|
||||
view: camera.view()
|
||||
});
|
||||
}
|
||||
|
||||
restartReglLoop() {
|
||||
const {
|
||||
regl,
|
||||
drawPoints,
|
||||
sizeBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
camera
|
||||
} = this.state;
|
||||
const reglRender = regl.frame(() => {
|
||||
this.reglDraw(
|
||||
regl,
|
||||
drawPoints,
|
||||
sizeBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
camera
|
||||
);
|
||||
camera.tick();
|
||||
});
|
||||
|
||||
this.reglRenderState = "rendering";
|
||||
|
||||
this.setState({
|
||||
reglRender
|
||||
});
|
||||
}
|
||||
|
||||
mapScreenToPoint(pin) {
|
||||
/*
|
||||
Map an XY coordinates from screen domain to cell/point range,
|
||||
accounting for current pan/zoom camera.
|
||||
*/
|
||||
|
||||
const { responsive } = this.props;
|
||||
const { regl, camera, transform } = this.state;
|
||||
const { glScaleX, glScaleY } = transform;
|
||||
const { camera, projectionTF, modelInvTF } = this.state;
|
||||
const cameraInvTF = camera.invView();
|
||||
|
||||
const gl = regl._gl;
|
||||
|
||||
// get aspect ratio
|
||||
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
|
||||
const scale = aspect < 1 ? 1 / aspect : 1;
|
||||
|
||||
// compute inverse view matrix
|
||||
const inverse = mat4.invert([], camera.view());
|
||||
|
||||
// transform screen coordinates -> cell coordinates
|
||||
/* screen -> gl */
|
||||
const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
|
||||
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
|
||||
const pout = [
|
||||
x * inverse[14] * aspect * scale + inverse[12],
|
||||
-(y * inverse[14] * scale + inverse[13])
|
||||
];
|
||||
|
||||
const xy = [glScaleX.invert(pout[0]), glScaleY.invert(pout[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;
|
||||
}
|
||||
|
||||
@@ -511,29 +540,21 @@ class Graph extends React.Component {
|
||||
Map an XY coordinate from cell/point domain to screen range. Inverse
|
||||
of mapScreenToPoint()
|
||||
*/
|
||||
|
||||
const { responsive } = this.props;
|
||||
const { regl, camera, transform } = this.state;
|
||||
const { glScaleX, glScaleY } = transform;
|
||||
const { camera, projectionTF, modelTF } = this.state;
|
||||
const cameraTF = camera.view();
|
||||
|
||||
const gl = regl._gl;
|
||||
|
||||
// get aspect ratio
|
||||
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
|
||||
const scale = aspect < 1 ? 1 / aspect : 1;
|
||||
|
||||
// compute inverse view matrix
|
||||
const inverse = mat4.invert([], camera.view());
|
||||
|
||||
// variable names are choosen to reflect inverse of those used
|
||||
// in mapScreenToPoint().
|
||||
const pout = [glScaleX(xyCell[0]), glScaleY(xyCell[1])];
|
||||
const x = (pout[0] - inverse[12]) / aspect / scale / inverse[14];
|
||||
const y = (-pout[1] - inverse[13]) / scale / inverse[14];
|
||||
const xy = vec2.transformMat3(vec2.create(), xyCell, modelTF);
|
||||
vec2.transformMat3(xy, xy, cameraTF);
|
||||
vec2.transformMat3(xy, xy, projectionTF);
|
||||
|
||||
const pin = [
|
||||
Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2),
|
||||
Math.round(
|
||||
-((y + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
|
||||
((xy[0] + 1) * (responsive.width - this.graphPaddingRight)) / 2
|
||||
),
|
||||
Math.round(
|
||||
-((xy[1] + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
|
||||
)
|
||||
];
|
||||
return pin;
|
||||
@@ -654,8 +675,61 @@ 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 || 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 { responsive } = this.props;
|
||||
|
||||
return (
|
||||
<div id="graphWrapper">
|
||||
@@ -676,6 +750,11 @@ class Graph extends React.Component {
|
||||
ref={canvas => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
onMouseDown={this.handleCanvasEvent}
|
||||
onMouseUp={this.handleCanvasEvent}
|
||||
onMouseMove={this.handleCanvasEvent}
|
||||
onDoubleClick={this.handleCanvasEvent}
|
||||
onWheel={this.handleCanvasEvent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,62 @@
|
||||
// jshint esversion: 6
|
||||
const mat4 = require("gl-mat4");
|
||||
|
||||
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
|
||||
import { glPointFlags } from "../../util/glHelpers";
|
||||
|
||||
export default function(regl) {
|
||||
return regl({
|
||||
vert: `
|
||||
precision mediump float;
|
||||
|
||||
attribute vec2 position;
|
||||
attribute vec3 color;
|
||||
attribute float size;
|
||||
uniform float distance;
|
||||
uniform mat4 projection, view;
|
||||
varying vec3 fragColor;
|
||||
attribute float flag;
|
||||
|
||||
uniform mat3 projection;
|
||||
uniform float nPoints;
|
||||
uniform float minViewportDimension;
|
||||
|
||||
varying vec4 fragColor;
|
||||
|
||||
const float zBottom = 0.99;
|
||||
const float zMiddle = 0.;
|
||||
const float zTop = -1.;
|
||||
|
||||
// import getFlags()
|
||||
${glPointFlags}
|
||||
|
||||
void main() {
|
||||
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
|
||||
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
|
||||
fragColor = color;
|
||||
bool isNaN, isSelected, isHighlight;
|
||||
getFlags(flag, isNaN, isSelected, isHighlight);
|
||||
|
||||
float size = isHighlight ? 8. : isSelected ? 4. : 1.;
|
||||
gl_PointSize = size;
|
||||
|
||||
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
|
||||
vec3 xy = projection * vec3(position, 1.);
|
||||
gl_Position = vec4(xy.xy, z, 1.);
|
||||
|
||||
float alpha = isNaN ? 0.9 : 1.0;
|
||||
fragColor = vec4(color, alpha);
|
||||
}`,
|
||||
|
||||
frag: `
|
||||
precision mediump float;
|
||||
varying vec3 fragColor;
|
||||
varying vec4 fragColor;
|
||||
void main() {
|
||||
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = vec4(fragColor, 1);
|
||||
gl_FragColor = fragColor;
|
||||
}`,
|
||||
|
||||
attributes: {
|
||||
position: regl.prop("position"),
|
||||
color: regl.prop("color"),
|
||||
size: regl.prop("size")
|
||||
flag: regl.prop("flag")
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
distance: regl.prop("distance"),
|
||||
view: regl.prop("view"),
|
||||
projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000)
|
||||
projection: regl.prop("projection"),
|
||||
nPoints: regl.prop("nPoints"),
|
||||
minViewportDimension: regl.prop("minViewportDimension")
|
||||
},
|
||||
|
||||
count: regl.prop("count"),
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
// jshint esversion: 6
|
||||
// https://bl.ocks.org/Jverma/076377dd0125b1a508621441752735fc
|
||||
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
|
||||
|
||||
import React from "react";
|
||||
import { connect } 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 * as globals from "../../globals";
|
||||
|
||||
import _camera from "../../util/camera";
|
||||
|
||||
import setupScatterplot from "./setupScatterplot";
|
||||
import styles from "./scatterplot.css";
|
||||
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import scaleLinear from "../../util/scaleLinear";
|
||||
|
||||
import { margin, width, height } from "./util";
|
||||
import finiteExtent from "../../util/finiteExtent";
|
||||
|
||||
function createProjectionTF(viewportWidth, viewportHeight) {
|
||||
/*
|
||||
the projection transform accounts for the screen size & other layout
|
||||
*/
|
||||
const m = mat3.create();
|
||||
return mat3.projection(m, viewportWidth, viewportHeight);
|
||||
}
|
||||
|
||||
@connect(state => {
|
||||
const { world, crossfilter } = state;
|
||||
const { world, crossfilter, universe } = state;
|
||||
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
|
||||
const expressionX =
|
||||
world &&
|
||||
scatterplotXXaccessor &&
|
||||
world.varData.hasCol(scatterplotXXaccessor)
|
||||
? world.varData.col(scatterplotXXaccessor).asArray()
|
||||
: null;
|
||||
world.varData.col(scatterplotXXaccessor)?.asArray();
|
||||
const expressionY =
|
||||
world &&
|
||||
scatterplotYYaccessor &&
|
||||
world.varData.hasCol(scatterplotYYaccessor)
|
||||
? world.varData.col(scatterplotYYaccessor).asArray()
|
||||
: null;
|
||||
world.varData.col(scatterplotYYaccessor)?.asArray();
|
||||
|
||||
return {
|
||||
world,
|
||||
universe,
|
||||
|
||||
colorRGB: state.colors.rgb,
|
||||
colorScale: state.colors.scale,
|
||||
@@ -57,6 +54,64 @@ import finiteExtent from "../../util/finiteExtent";
|
||||
};
|
||||
})
|
||||
class Scatterplot extends React.Component {
|
||||
computePointPositions = memoize((X, Y, xScale, yScale) => {
|
||||
const positions = new Float32Array(2 * X.length);
|
||||
for (let i = 0, len = X.length; i < len; i += 1) {
|
||||
positions[2 * i] = xScale(X[i]);
|
||||
positions[2 * i + 1] = yScale(Y[i]);
|
||||
}
|
||||
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) => {
|
||||
const flagSelected = 1;
|
||||
const flagNaN = 2;
|
||||
// XXX - coming soon.
|
||||
// const flagHighlight = 4;
|
||||
|
||||
const flags = this.computeSelectedFlags(
|
||||
crossfilter,
|
||||
flagSelected,
|
||||
0
|
||||
).slice();
|
||||
|
||||
const colorByColumn = colorAccessor
|
||||
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
|
||||
world.varData.col(colorAccessor)?.asArray()
|
||||
: null;
|
||||
const colorByData =
|
||||
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
|
||||
|
||||
if (colorByData) {
|
||||
for (let i = 0, len = flags.length; i < len; i += 1) {
|
||||
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
});
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.count = 0;
|
||||
@@ -64,7 +119,7 @@ class Scatterplot extends React.Component {
|
||||
this.renderCache = {
|
||||
positions: null,
|
||||
colors: null,
|
||||
sizes: null,
|
||||
flags: null,
|
||||
xScale: null,
|
||||
yScale: null
|
||||
};
|
||||
@@ -85,39 +140,37 @@ class Scatterplot extends React.Component {
|
||||
this.renderCache = { ...this.renderCache, ...scales };
|
||||
}
|
||||
|
||||
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
|
||||
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 sizeBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
const reglRender = regl.frame(() => {
|
||||
this.reglDraw(
|
||||
regl,
|
||||
drawPoints,
|
||||
sizeBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
camera
|
||||
);
|
||||
camera.tick();
|
||||
});
|
||||
|
||||
this.reglRenderState = "rendering";
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
);
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
sizeBuffer,
|
||||
flagBuffer,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
svg,
|
||||
reglRender,
|
||||
camera,
|
||||
drawPoints
|
||||
drawPoints,
|
||||
projectionTF
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,22 +182,22 @@ class Scatterplot extends React.Component {
|
||||
scatterplotYYaccessor,
|
||||
expressionX,
|
||||
expressionY,
|
||||
colorRGB
|
||||
colorRGB,
|
||||
colorAccessor
|
||||
} = this.props;
|
||||
const {
|
||||
reglRender,
|
||||
regl,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
sizeBuffer,
|
||||
flagBuffer,
|
||||
svg,
|
||||
drawPoints,
|
||||
camera
|
||||
projectionTF
|
||||
} = this.state;
|
||||
|
||||
if (
|
||||
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
|
||||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
|
||||
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor ||
|
||||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor ||
|
||||
world !== prevProps.world // shape or clip of world changed
|
||||
) {
|
||||
const scales = Scatterplot.setupScales(expressionX, expressionY);
|
||||
@@ -152,76 +205,54 @@ class Scatterplot extends React.Component {
|
||||
this.renderCache = { ...this.renderCache, ...scales };
|
||||
}
|
||||
|
||||
if (reglRender && this.reglRenderState === "rendering") {
|
||||
reglRender.cancel();
|
||||
this.reglRenderState = "paused";
|
||||
}
|
||||
|
||||
if (
|
||||
world &&
|
||||
regl &&
|
||||
pointBuffer &&
|
||||
colorBuffer &&
|
||||
sizeBuffer &&
|
||||
expressionX &&
|
||||
expressionY &&
|
||||
scatterplotXXaccessor &&
|
||||
scatterplotYYaccessor
|
||||
) {
|
||||
if (world && regl) {
|
||||
const { renderCache } = this;
|
||||
const { xScale, yScale } = this.renderCache;
|
||||
const cellCount = expressionX.length;
|
||||
let needsRepaint = false;
|
||||
|
||||
// Points change when expressionX or expressionY change.
|
||||
if (
|
||||
!renderCache.positions ||
|
||||
expressionX !== prevProps.expressionX ||
|
||||
expressionY !== prevProps.expressionY
|
||||
) {
|
||||
if (!renderCache.positions) {
|
||||
renderCache.positions = new Float32Array(2 * cellCount);
|
||||
}
|
||||
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
|
||||
const glScaleY = scaleLinear([0, height], [-1, 1]);
|
||||
for (let i = 0, { positions } = renderCache; i < cellCount; i += 1) {
|
||||
positions[2 * i] = glScaleX(xScale(expressionX[i]));
|
||||
positions[2 * i + 1] = glScaleY(yScale(expressionY[i]));
|
||||
}
|
||||
pointBuffer({ data: renderCache.positions, dimension: 2 });
|
||||
}
|
||||
|
||||
// Colors for each point - change only when props.colorsRGB change.
|
||||
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) {
|
||||
if (!renderCache.colors) {
|
||||
renderCache.colors = new Float32Array(3 * cellCount);
|
||||
}
|
||||
for (let i = 0, { colors } = renderCache; i < cellCount; i += 1) {
|
||||
colors.set(colorRGB[i], 3 * i);
|
||||
}
|
||||
colorBuffer({ data: renderCache.colors, dimension: 3 });
|
||||
}
|
||||
|
||||
// Sizes for each point - updates are triggered only when selected
|
||||
// obs change
|
||||
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) {
|
||||
if (!renderCache.sizes) {
|
||||
renderCache.sizes = new Float32Array(cellCount);
|
||||
}
|
||||
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
|
||||
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
|
||||
}
|
||||
|
||||
this.count = cellCount;
|
||||
|
||||
regl._refresh();
|
||||
this.reglDraw(
|
||||
regl,
|
||||
drawPoints,
|
||||
sizeBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
camera
|
||||
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
|
||||
);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,22 +272,6 @@ class Scatterplot extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1]
|
||||
});
|
||||
|
||||
drawPoints({
|
||||
size: sizeBuffer,
|
||||
distance: camera.distance,
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
count: this.count,
|
||||
view: camera.view()
|
||||
});
|
||||
}
|
||||
|
||||
drawAxesSVG(xScale, yScale, svg) {
|
||||
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
|
||||
svg.selectAll("*").remove();
|
||||
@@ -307,6 +322,34 @@ class Scatterplot extends React.Component {
|
||||
.text(scatterplotXXaccessor);
|
||||
}
|
||||
|
||||
renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
) {
|
||||
if (!this.reglCanvas) return;
|
||||
const { universe } = this.props;
|
||||
const { width: cvWidth, height: cvHeight } = this.reglCanvas;
|
||||
regl.poll();
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1]
|
||||
});
|
||||
drawPoints({
|
||||
flag: flagBuffer,
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
projection: projectionTF,
|
||||
count: this.count,
|
||||
nPoints: universe.nObs,
|
||||
minViewportDimension: Math.min(cvWidth || width, cvHeight || height)
|
||||
});
|
||||
regl._gl.flush();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dispatch } = this.props;
|
||||
const { minimized } = this.state;
|
||||
@@ -367,7 +410,7 @@ class Scatterplot extends React.Component {
|
||||
height={height}
|
||||
data-testid="scatterplot"
|
||||
style={{
|
||||
marginLeft: margin.left - 7,
|
||||
marginLeft: margin.left,
|
||||
marginTop: margin.top
|
||||
}}
|
||||
ref={canvas => {
|
||||
|
||||
Reference in New Issue
Block a user