mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-28 08: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:
@@ -27,7 +27,7 @@ export const datasets = {
|
||||
lasso: [
|
||||
{
|
||||
"coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 },
|
||||
count: "71"
|
||||
count: "88"
|
||||
}
|
||||
],
|
||||
categorical: [
|
||||
@@ -92,7 +92,7 @@ export const datasets = {
|
||||
},
|
||||
lasso: {
|
||||
"coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 },
|
||||
count: "36"
|
||||
count: "39"
|
||||
}
|
||||
},
|
||||
scatter: {
|
||||
|
||||
Generated
+4
-103
@@ -4816,14 +4816,6 @@
|
||||
"integrity": "sha512-rUBIbap+VJfxTzrM4akJ00lkvVb5/n5v3EGXfWzSH5zT8aJmGzjA8HWhJ4U6kCpzxozUSnB+yvAYDRPY6mRpgQ==",
|
||||
"dev": true
|
||||
},
|
||||
"canvas-fit": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz",
|
||||
"integrity": "sha1-rhO+Zq3kL1vg5IfjRfzjCl5bXl8=",
|
||||
"requires": {
|
||||
"element-size": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"capture-exit": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz",
|
||||
@@ -5888,7 +5880,8 @@
|
||||
"decode-uri-component": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
|
||||
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
|
||||
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
|
||||
"dev": true
|
||||
},
|
||||
"deep-extend": {
|
||||
"version": "0.6.0",
|
||||
@@ -6200,11 +6193,6 @@
|
||||
"integrity": "sha512-lyoC8aoqbbDqsprb6aPdt9n3DpOZZzdz/T4IZKsR0/dkZIxnJVUjjcpOSwA66jPRIOyDAamCTAUqweU05kKNSg==",
|
||||
"dev": true
|
||||
},
|
||||
"element-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz",
|
||||
"integrity": "sha1-ZOXxWdlxIWMYRby67K8nnDm1404="
|
||||
},
|
||||
"elliptic": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz",
|
||||
@@ -6890,11 +6878,6 @@
|
||||
"through": "~2.3.1"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
|
||||
"integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ="
|
||||
},
|
||||
"evp_bytestokey": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
|
||||
@@ -7618,11 +7601,6 @@
|
||||
"readable-stream": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"font-color-contrast": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/font-color-contrast/-/font-color-contrast-1.0.3.tgz",
|
||||
"integrity": "sha1-d9iP+Xxr0JXPqd/rRGJLqK1xe2w="
|
||||
},
|
||||
"for-in": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
||||
@@ -11673,14 +11651,6 @@
|
||||
"array-includes": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"key-pressed": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/key-pressed/-/key-pressed-0.0.1.tgz",
|
||||
"integrity": "sha1-1oL7THwQQpKaW8iGNnKMpxIg3rs=",
|
||||
"requires": {
|
||||
"vkey": "0.0.3"
|
||||
}
|
||||
},
|
||||
"kind-of": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
|
||||
@@ -12311,22 +12281,6 @@
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"mouse-position": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mouse-position/-/mouse-position-2.1.0.tgz",
|
||||
"integrity": "sha512-Ra8/RFn9e9/D2v9syhF5bLo4yiWvqZzcC/5bHOvkixDAkvO3WhlWN2xQRHSirMoYk2bagIae2upXXwxTxzI3BQ==",
|
||||
"requires": {
|
||||
"events": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"mouse-pressed": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mouse-pressed/-/mouse-pressed-1.0.0.tgz",
|
||||
"integrity": "sha1-wB4UGA+NoSBQc5lZOeT5b+8ifjI=",
|
||||
"requires": {
|
||||
"events": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"move-concurrently": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
|
||||
@@ -12796,21 +12750,6 @@
|
||||
"wordwrap": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"orbit-camera": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/orbit-camera/-/orbit-camera-1.0.0.tgz",
|
||||
"integrity": "sha1-jaFhWrXB53eiv2W5XlzR9Rfx/DE=",
|
||||
"requires": {
|
||||
"gl-matrix": "^2.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gl-matrix": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-2.8.1.tgz",
|
||||
"integrity": "sha512-0YCjVpE3pS5XWlN3J4X7AiAx65+nqAI54LndtVFnQZB6G/FVLkZH8y8V6R3cIoOQR4pUdfwQGd1iwyoXHJ4Qfw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"os-browserify": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
|
||||
@@ -13512,16 +13451,6 @@
|
||||
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
|
||||
"dev": true
|
||||
},
|
||||
"query-string": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-6.5.0.tgz",
|
||||
"integrity": "sha512-TYC4hDjZSvVxLMEucDMySkuAS9UIzSbAiYGyA9GWCjLKB8fQpviFbjd20fD7uejCDxZS+ftSdBKE6DS+xucJFg==",
|
||||
"requires": {
|
||||
"decode-uri-component": "^0.2.0",
|
||||
"split-on-first": "^1.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
@@ -14595,15 +14524,6 @@
|
||||
"ajv-keywords": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"scroll-speed": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/scroll-speed/-/scroll-speed-1.0.0.tgz",
|
||||
"integrity": "sha1-3P5AlRbnYXVZe2BgACtRznLubtc=",
|
||||
"requires": {
|
||||
"events": "^1.0.0",
|
||||
"wheel": "0.0.0"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
|
||||
@@ -15070,11 +14990,6 @@
|
||||
"through": "2"
|
||||
}
|
||||
},
|
||||
"split-on-first": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
|
||||
},
|
||||
"split-string": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
|
||||
@@ -15269,11 +15184,6 @@
|
||||
"integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=",
|
||||
"dev": true
|
||||
},
|
||||
"strict-uri-encode": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||
"integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
|
||||
},
|
||||
"string-length": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz",
|
||||
@@ -16259,7 +16169,8 @@
|
||||
"urijs": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.1.tgz",
|
||||
"integrity": "sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg=="
|
||||
"integrity": "sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg==",
|
||||
"dev": true
|
||||
},
|
||||
"urix": {
|
||||
"version": "0.1.0",
|
||||
@@ -16395,11 +16306,6 @@
|
||||
"extsprintf": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"vkey": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vkey/-/vkey-0.0.3.tgz",
|
||||
"integrity": "sha1-Y03XlS6ywRUxvxhZ8ObUGUeAM98="
|
||||
},
|
||||
"vm-browserify": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz",
|
||||
@@ -16719,11 +16625,6 @@
|
||||
"webidl-conversions": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wheel/-/wheel-0.0.0.tgz",
|
||||
"integrity": "sha1-BP+b6YcXXese3U8kgui2V/MqQWU="
|
||||
},
|
||||
"which": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
|
||||
+1
-10
@@ -34,23 +34,16 @@
|
||||
"@blueprintjs/core": "^3.15.1",
|
||||
"@blueprintjs/icons": "^3.8.0",
|
||||
"@blueprintjs/select": "^3.8.0",
|
||||
"canvas-fit": "^1.5.0",
|
||||
"d3": "^4.10.0",
|
||||
"d3-scale-chromatic": "^1.3.0",
|
||||
"flatbuffers": "^1.10.2",
|
||||
"font-color-contrast": "^1.0.3",
|
||||
"fuzzysort": "^1.1.4",
|
||||
"gl-mat4": "^1.1.4",
|
||||
"gl-matrix": "^3.0.0",
|
||||
"gl-vec3": "^1.1.3",
|
||||
"is-number": "^7.0.0",
|
||||
"key-pressed": "0.0.1",
|
||||
"lodash": "^4.17.14",
|
||||
"memoize-one": "^5.0.4",
|
||||
"mouse-position": "^2.0.1",
|
||||
"mouse-pressed": "^1.0.0",
|
||||
"orbit-camera": "^1.0.0",
|
||||
"query-string": "^6.5.0",
|
||||
"react": "^16.8.6",
|
||||
"react-autocomplete": "^1.7.2",
|
||||
"react-dom": "^16.8.6",
|
||||
@@ -59,9 +52,7 @@
|
||||
"react-redux": "^7.0.3",
|
||||
"redux": "^4.0.1",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"regl": "^1.3.11",
|
||||
"scroll-speed": "^1.0.0",
|
||||
"urijs": "^1.19.0"
|
||||
"regl": "^1.3.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.4.4",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
+141
-51
@@ -1,66 +1,156 @@
|
||||
// jshint esversion: 6
|
||||
const createCamera = require("orbit-camera");
|
||||
const createScroll = require("scroll-speed");
|
||||
const mp = require("mouse-position");
|
||||
const mb = require("mouse-pressed");
|
||||
const key = require("key-pressed");
|
||||
import { vec2, mat3 } from "gl-matrix";
|
||||
|
||||
const panSpeed = 1.0; // changed from 0.4 to 1.0 per issue #722
|
||||
const scaleSpeed = 0.5;
|
||||
const scaleMax = 3;
|
||||
const scaleMin = 1.03;
|
||||
const scaleMax = 3.0;
|
||||
const scaleMin = 0.5;
|
||||
|
||||
function attachCamera(canvas, opts) {
|
||||
opts = opts || {};
|
||||
opts.pan = opts.pan !== false;
|
||||
opts.scale = opts.scale !== false;
|
||||
opts.rotate = opts.rotate !== false;
|
||||
// private
|
||||
const scratch0 = new Float32Array(16);
|
||||
const scratch1 = new Float32Array(16);
|
||||
|
||||
const scroll = createScroll(canvas, opts.scale);
|
||||
const mbut = mb(canvas, opts.rotate);
|
||||
const mpos = mp(canvas);
|
||||
const camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]);
|
||||
class Camera {
|
||||
constructor(canvas) {
|
||||
this.prevEvent = {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
type: 0
|
||||
};
|
||||
this.canvas = canvas;
|
||||
this.viewMatrix = mat3.create();
|
||||
this.viewMatrixInv = mat3.create();
|
||||
}
|
||||
|
||||
camera.tick = tick;
|
||||
view() {
|
||||
return this.viewMatrix;
|
||||
}
|
||||
|
||||
return camera;
|
||||
invView() {
|
||||
return this.viewMatrixInv;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const ctrl = key("<control>") || key("<alt>");
|
||||
const alt = key("<shift>");
|
||||
const { height, width } = canvas;
|
||||
distance() {
|
||||
return this.viewMatrix[0];
|
||||
}
|
||||
|
||||
if (opts.rotate && mbut.left && ctrl && !alt) {
|
||||
camera.rotate(
|
||||
[mpos.x / width - 0.5, mpos.y / height - 0.5],
|
||||
[mpos.prevX / width - 0.5, mpos.prevY / height - 0.5]
|
||||
);
|
||||
pan(dx, dy) {
|
||||
mat3.translate(this.viewMatrix, this.viewMatrix, [dx, dy]);
|
||||
mat3.invert(this.viewMatrixInv, this.viewMatrix);
|
||||
}
|
||||
|
||||
zoomAt(d, x = 0, y = 0) {
|
||||
/*
|
||||
Set camera zoom at [x,y]
|
||||
*/
|
||||
const dClamped =
|
||||
Math.max(Math.min(d * this.viewMatrix[0], scaleMax), scaleMin) /
|
||||
this.viewMatrix[0];
|
||||
if (dClamped === 1) return;
|
||||
|
||||
mat3.translate(this.viewMatrix, this.viewMatrix, [x, y]);
|
||||
mat3.scale(this.viewMatrix, this.viewMatrix, [dClamped, dClamped]);
|
||||
mat3.translate(this.viewMatrix, this.viewMatrix, [-x, -y]);
|
||||
|
||||
mat3.invert(this.viewMatrixInv, this.viewMatrix);
|
||||
}
|
||||
|
||||
/*
|
||||
Event handling
|
||||
*/
|
||||
|
||||
flush(e) {
|
||||
this.prevEvent.type = e.type;
|
||||
this.prevEvent.clientX = e.clientX;
|
||||
this.prevEvent.clientY = e.clientY;
|
||||
}
|
||||
|
||||
localPosition(target, canvasX, canvasY, projectionInvTF) {
|
||||
/*
|
||||
Convert mouse position to local
|
||||
*/
|
||||
const { height, width } = target;
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
canvasX -= targetRect.left;
|
||||
canvasY -= targetRect.top;
|
||||
|
||||
const pos = vec2.fromValues(
|
||||
2 * (canvasX / width) - 1,
|
||||
-2 * (canvasY / height) + 1
|
||||
);
|
||||
if (projectionInvTF) {
|
||||
vec2.transformMat3(pos, pos, projectionInvTF);
|
||||
}
|
||||
vec2.transformMat3(pos, pos, this.invView());
|
||||
return pos;
|
||||
}
|
||||
|
||||
if ((opts.pan && mbut.right) || (mbut.left && !ctrl && !alt)) {
|
||||
camera.pan([
|
||||
((panSpeed * (mpos[0] - mpos.prev[0])) / width) * camera.distance,
|
||||
((panSpeed * (mpos[1] - mpos.prev[1])) / height) * camera.distance
|
||||
]);
|
||||
mousePan(e, projectionTF) {
|
||||
const projectionInvTF = mat3.invert(scratch0, projectionTF);
|
||||
const pos = this.localPosition(
|
||||
this.canvas,
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
projectionInvTF
|
||||
);
|
||||
const prev = this.localPosition(
|
||||
this.canvas,
|
||||
this.prevEvent.clientX,
|
||||
this.prevEvent.clientY,
|
||||
projectionInvTF
|
||||
);
|
||||
|
||||
const delta = vec2.sub(scratch1, pos, prev);
|
||||
this.pan(delta[0], delta[1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
wheelZoom(e, projectionTF) {
|
||||
const { height } = this.canvas;
|
||||
const { deltaY, deltaMode, clientX, clientY } = e;
|
||||
const scale = scaleSpeed * (deltaMode === 1 ? 12 : 1) * (deltaY || 0);
|
||||
|
||||
const projectionInvTF = mat3.invert(scratch0, projectionTF);
|
||||
const pos = this.localPosition(
|
||||
this.canvas,
|
||||
clientX,
|
||||
clientY,
|
||||
projectionInvTF
|
||||
);
|
||||
this.zoomAt(1 / Math.exp(scale / height), pos[0], pos[1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
handleEvent(e, projectionTF) {
|
||||
/*
|
||||
process the event, and return true if camera view changed
|
||||
*/
|
||||
let viewChanged = false;
|
||||
switch (e.type) {
|
||||
case "mousemove": {
|
||||
/* eslint-disable no-bitwise */
|
||||
if (e.buttons & 0x1) {
|
||||
viewChanged = this.mousePan(e, projectionTF);
|
||||
}
|
||||
/* eslint-enable no-bitwise */
|
||||
this.flush(e);
|
||||
break;
|
||||
}
|
||||
|
||||
case "wheel": {
|
||||
viewChanged = this.wheelZoom(e, projectionTF);
|
||||
this.flush(e);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// noop
|
||||
break;
|
||||
}
|
||||
|
||||
if (opts.scale && scroll[1]) {
|
||||
camera.distance *= Math.exp((scroll[1] * scaleSpeed) / height);
|
||||
}
|
||||
|
||||
if (opts.scale && (mbut.middle || (mbut.left && !ctrl && alt))) {
|
||||
const d = mpos.y - mpos.prevY;
|
||||
if (!d) return;
|
||||
|
||||
camera.distance *= Math.exp(d / height);
|
||||
}
|
||||
|
||||
if (camera.distance > scaleMax) camera.distance = scaleMax;
|
||||
if (camera.distance < scaleMin) camera.distance = scaleMin;
|
||||
|
||||
scroll.flush();
|
||||
mpos.flush();
|
||||
return viewChanged;
|
||||
}
|
||||
}
|
||||
|
||||
function attachCamera(canvas) {
|
||||
return new Camera(canvas);
|
||||
}
|
||||
|
||||
export default attachCamera;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Utility code for our WebGL shaders
|
||||
*/
|
||||
|
||||
/*
|
||||
Point flags - used in graph & scatter plots
|
||||
|
||||
We want a bitmask-like flag structure, but due to webgl limitations
|
||||
must emulate it with floats.
|
||||
*/
|
||||
|
||||
// for JS
|
||||
export const flagSelected = 1;
|
||||
export const flagNaN = 2;
|
||||
export const flagHighlight = 4;
|
||||
|
||||
// for GLSL
|
||||
export const glPointFlags = `
|
||||
|
||||
const float flagSelected = 1.;
|
||||
const float flagNaN = 2.;
|
||||
const float flagHighlight = 4.;
|
||||
|
||||
bool isLowBitSet(float f) {
|
||||
f = mod(f, 2.);
|
||||
return (f > 0.9 && f <= 1.1);
|
||||
}
|
||||
|
||||
float shiftRightOne(float f) {
|
||||
return floor(f / 2.);
|
||||
}
|
||||
|
||||
void getFlags(in float flag,
|
||||
out bool isNaN,
|
||||
out bool isSelected,
|
||||
out bool isHighlight) {
|
||||
isSelected = isLowBitSet(flag);
|
||||
flag = shiftRightOne(flag);
|
||||
isNaN = isLowBitSet(flag);
|
||||
flag = shiftRightOne(flag);
|
||||
isHighlight = isLowBitSet(flag);
|
||||
}
|
||||
|
||||
`;
|
||||
Reference in New Issue
Block a user