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:
Bruce Martin
2019-08-22 20:56:05 -07:00
committed by GitHub
parent 28c4d28308
commit 0f36b49840
9 changed files with 708 additions and 536 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ export const datasets = {
lasso: [ lasso: [
{ {
"coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 }, "coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 },
count: "71" count: "88"
} }
], ],
categorical: [ categorical: [
@@ -92,7 +92,7 @@ export const datasets = {
}, },
lasso: { lasso: {
"coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 }, "coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 },
count: "36" count: "39"
} }
}, },
scatter: { scatter: {
+4 -103
View File
@@ -4816,14 +4816,6 @@
"integrity": "sha512-rUBIbap+VJfxTzrM4akJ00lkvVb5/n5v3EGXfWzSH5zT8aJmGzjA8HWhJ4U6kCpzxozUSnB+yvAYDRPY6mRpgQ==", "integrity": "sha512-rUBIbap+VJfxTzrM4akJ00lkvVb5/n5v3EGXfWzSH5zT8aJmGzjA8HWhJ4U6kCpzxozUSnB+yvAYDRPY6mRpgQ==",
"dev": true "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": { "capture-exit": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz",
@@ -5888,7 +5880,8 @@
"decode-uri-component": { "decode-uri-component": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "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": { "deep-extend": {
"version": "0.6.0", "version": "0.6.0",
@@ -6200,11 +6193,6 @@
"integrity": "sha512-lyoC8aoqbbDqsprb6aPdt9n3DpOZZzdz/T4IZKsR0/dkZIxnJVUjjcpOSwA66jPRIOyDAamCTAUqweU05kKNSg==", "integrity": "sha512-lyoC8aoqbbDqsprb6aPdt9n3DpOZZzdz/T4IZKsR0/dkZIxnJVUjjcpOSwA66jPRIOyDAamCTAUqweU05kKNSg==",
"dev": true "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": { "elliptic": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz",
@@ -6890,11 +6878,6 @@
"through": "~2.3.1" "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": { "evp_bytestokey": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
@@ -7618,11 +7601,6 @@
"readable-stream": "^2.0.4" "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": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@@ -11673,14 +11651,6 @@
"array-includes": "^3.0.3" "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": { "kind-of": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
@@ -12311,22 +12281,6 @@
"minimist": "0.0.8" "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": { "move-concurrently": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -12796,21 +12750,6 @@
"wordwrap": "~1.0.0" "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": { "os-browserify": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", "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==", "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
"dev": true "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": { "querystring": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
@@ -14595,15 +14524,6 @@
"ajv-keywords": "^3.1.0" "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": { "semver": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
@@ -15070,11 +14990,6 @@
"through": "2" "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": { "split-string": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
@@ -15269,11 +15184,6 @@
"integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=",
"dev": true "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": { "string-length": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz",
@@ -16259,7 +16169,8 @@
"urijs": { "urijs": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.1.tgz", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.1.tgz",
"integrity": "sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg==" "integrity": "sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg==",
"dev": true
}, },
"urix": { "urix": {
"version": "0.1.0", "version": "0.1.0",
@@ -16395,11 +16306,6 @@
"extsprintf": "^1.2.0" "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": { "vm-browserify": {
"version": "0.0.4", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz",
@@ -16719,11 +16625,6 @@
"webidl-conversions": "^4.0.2" "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": { "which": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+1 -10
View File
@@ -34,23 +34,16 @@
"@blueprintjs/core": "^3.15.1", "@blueprintjs/core": "^3.15.1",
"@blueprintjs/icons": "^3.8.0", "@blueprintjs/icons": "^3.8.0",
"@blueprintjs/select": "^3.8.0", "@blueprintjs/select": "^3.8.0",
"canvas-fit": "^1.5.0",
"d3": "^4.10.0", "d3": "^4.10.0",
"d3-scale-chromatic": "^1.3.0", "d3-scale-chromatic": "^1.3.0",
"flatbuffers": "^1.10.2", "flatbuffers": "^1.10.2",
"font-color-contrast": "^1.0.3",
"fuzzysort": "^1.1.4", "fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4", "gl-mat4": "^1.1.4",
"gl-matrix": "^3.0.0", "gl-matrix": "^3.0.0",
"gl-vec3": "^1.1.3", "gl-vec3": "^1.1.3",
"is-number": "^7.0.0", "is-number": "^7.0.0",
"key-pressed": "0.0.1",
"lodash": "^4.17.14", "lodash": "^4.17.14",
"memoize-one": "^5.0.4", "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": "^16.8.6",
"react-autocomplete": "^1.7.2", "react-autocomplete": "^1.7.2",
"react-dom": "^16.8.6", "react-dom": "^16.8.6",
@@ -59,9 +52,7 @@
"react-redux": "^7.0.3", "react-redux": "^7.0.3",
"redux": "^4.0.1", "redux": "^4.0.1",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"regl": "^1.3.11", "regl": "^1.3.11"
"scroll-speed": "^1.0.0",
"urijs": "^1.19.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.4.4", "@babel/core": "^7.4.4",
+35 -29
View File
@@ -1,58 +1,64 @@
// jshint esversion: 6 import { glPointFlags } from "../../util/glHelpers";
const mat4 = require("gl-mat4");
const vec3 = require("gl-vec3");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
export default function(regl) { export default function(regl) {
return regl({ return regl({
vert: ` vert: `
precision mediump float; precision mediump float;
attribute vec2 position; attribute vec2 position;
attribute vec3 color; attribute vec3 color;
attribute float size; attribute float flag;
uniform float distance; uniform float distance;
uniform mat4 projection, view; uniform mat3 projView;
varying vec3 fragColor; 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() { void main() {
gl_PointSize = 7.0 / pow(distance, 2.5) + size; bool isNaN, isSelected, isHighlight;
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1); getFlags(flag, isNaN, isSelected, isHighlight);
fragColor = color;
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: ` frag: `
precision mediump float; precision mediump float;
varying vec3 fragColor; varying vec4 fragColor;
void main() { void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) { if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard; discard;
} }
gl_FragColor = vec4(fragColor, 1); gl_FragColor = fragColor;
}`, }`,
attributes: { attributes: {
position: regl.prop("position"), position: regl.prop("position"),
color: regl.prop("color"), color: regl.prop("color"),
size: regl.prop("size") flag: regl.prop("flag")
}, },
uniforms: { uniforms: {
distance: regl.prop("distance"), distance: regl.prop("distance"),
view: regl.prop("view"), projView: regl.prop("projView"),
projection: ({ viewportWidth, viewportHeight }) => { nPoints: regl.prop("nPoints"),
const aspectRatio = viewportWidth / viewportHeight; minViewportDimension: regl.prop("minViewportDimension")
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;
}
}, },
count: regl.prop("count"), count: regl.prop("count"),
+274 -195
View File
@@ -2,21 +2,70 @@
import React from "react"; import React from "react";
import * as d3 from "d3"; import * as d3 from "d3";
import { connect } from "react-redux"; import { connect } from "react-redux";
import mat4 from "gl-mat4"; import { mat3, vec2 } from "gl-matrix";
import _regl from "regl"; import _regl from "regl";
import memoize from "memoize-one"; import memoize from "memoize-one";
import * as globals from "../../globals"; import * as globals from "../../globals";
import setupSVGandBrushElements from "./setupSVGandBrush"; import setupSVGandBrushElements from "./setupSVGandBrush";
import setupCentroidSVG from "./setupCentroidSVG"; import setupCentroidSVG from "./setupCentroidSVG";
import actions from "../../actions";
import _camera from "../../util/camera"; import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl"; 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 => ({ @connect(state => ({
universe: state.universe,
world: state.world, world: state.world,
crossfilter: state.crossfilter, crossfilter: state.crossfilter,
responsive: state.responsive, responsive: state.responsive,
@@ -29,14 +78,16 @@ import scaleLinear from "../../util/scaleLinear";
colorAccessor: state.colors.colorAccessor colorAccessor: state.colors.colorAccessor
})) }))
class Graph extends React.Component { 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); const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) { for (let i = 0, len = X.length; i < len; i += 1) {
positions[2 * i] = scaleX(X[i]); const p = vec2.fromValues(X[i], Y[i]);
positions[2 * i + 1] = scaleY(Y[i]); vec2.transformMat3(p, p, modelTF);
positions[2 * i] = p[0];
positions[2 * i + 1] = p[1];
} }
return positions; return positions;
}); });
@@ -52,38 +103,65 @@ class Graph extends React.Component {
return colors; return colors;
}); });
computePointSizesFromCrossfilter = memoize((len, crossfilter) => { computeSelectedFlags = memoize(
const sizes = new Float32Array(len); (crossfilter, flagSelected, flagUnselected) => {
crossfilter.fillByIsSelected(sizes, 4, 0.2); const x = crossfilter.fillByIsSelected(
new Float32Array(crossfilter.size()),
return sizes; flagSelected,
}); flagUnselected
computePointSizes = memoize(
(len, crossfilter, metadataField, categoryField) => {
/*
compute webgl dot size for each point
*/
const selectionSizes = this.computePointSizesFromCrossfilter(
len,
crossfilter
); );
let sizes; return x;
}
);
if (metadataField && categoryField) { computePointFlags = memoize(
sizes = selectionSizes.slice(); (world, crossfilter, colorAccessor, centroidLabel) => {
const valuesArr = crossfilter.data.col(metadataField).asArray(); /*
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) { Due to constraints in webgl vertex shader attributes, these are encoded in a float, "kinda"
if (valuesArr[i] === categoryField) { like bitmasks.
sizes[i] = 10;
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, Y: null,
positions: null, positions: null,
colors: null, colors: null,
sizes: null sizes: null,
flags: null
}; };
this.state = { this.state = {
toolSVG: null, toolSVG: null,
@@ -108,55 +187,48 @@ class Graph extends React.Component {
} }
componentDidMount() { componentDidMount() {
// setup canvas and camera // setup canvas, webgl draw function and camera
const camera = _camera(this.reglCanvas, { scale: true, rotate: false }); const camera = _camera(this.reglCanvas, {
pan: true,
scale: true,
rotate: false
});
const regl = _regl(this.reglCanvas); const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl); const drawPoints = _drawPoints(regl);
// preallocate buffers // preallocate webgl buffers
const pointBuffer = regl.buffer(); const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer(); const colorBuffer = regl.buffer();
const sizeBuffer = regl.buffer(); const flagBuffer = regl.buffer();
// preallocate coordinate system transformation between data and gl // create all default rendering transformations
const fractionToUse = 0.93; // fraction of dimension to use const modelTF = createModelTF();
const shiftForMenuBar = 0.05; const projectionTF = createProjectionTF(
const transform = { this.reglCanvas.width,
glScaleX: scaleLinear([0, 1], [-1 * fractionToUse, 1 * fractionToUse]), this.reglCanvas.height
glScaleY: scaleLinear( );
[0, 1],
[
(1 + shiftForMenuBar) * fractionToUse,
(-1 + shiftForMenuBar) * fractionToUse
]
)
};
/* first time, but this duplicates above function, should be possile to avoid this */ // initial draw to canvas
const reglRender = regl.frame(() => { this.renderPoints(
this.reglDraw( regl,
regl, drawPoints,
drawPoints, colorBuffer,
sizeBuffer, pointBuffer,
colorBuffer, flagBuffer,
pointBuffer, camera,
camera projectionTF
); );
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({ this.setState({
regl, regl,
drawPoints, drawPoints,
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
sizeBuffer, flagBuffer,
camera, camera,
reglRender, modelTF,
transform modelInvTF: mat3.invert([], modelTF),
projectionTF
}); });
} }
@@ -174,50 +246,47 @@ class Graph extends React.Component {
colorAccessor, colorAccessor,
centroidLabel centroidLabel
} = this.props; } = this.props;
const { reglRender, mode, regl, toolSVG, centroidSVG } = this.state; const { regl, toolSVG, centroidSVG } = this.state;
let stateChanges = {}; 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) { if (regl && world) {
/* update the regl state */ /* update the regl and point rendering state */
const { obsLayout, nObs } = world; const { obsLayout, nObs } = world;
const { const {
drawPoints, drawPoints,
transform,
camera, camera,
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
sizeBuffer flagBuffer,
modelTF
} = this.state; } = 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 */ /* coordinates for each point */
const { glScaleX, glScaleY } = transform;
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray(); const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).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) { if (renderCache.positions !== newPositions) {
/* update our cache & GL if the buffer changes */ /* update our cache & GL if the buffer changes */
renderCache.positions = newPositions; renderCache.positions = newPositions;
pointBuffer({ data: newPositions, dimension: 2 }); pointBuffer({ data: newPositions, dimension: 2 });
needsRepaint = true;
} }
/* colors for each point */ /* colors for each point */
@@ -226,33 +295,35 @@ class Graph extends React.Component {
/* update our cache & GL if the buffer changes */ /* update our cache & GL if the buffer changes */
renderCache.colors = newColors; renderCache.colors = newColors;
colorBuffer({ data: newColors, dimension: 3 }); colorBuffer({ data: newColors, dimension: 3 });
needsRepaint = true;
} }
/* sizes for each point */ /* flags for each point */
const { metadataField, categoryField } = centroidLabel; const newFlags = this.computePointFlags(
const newSizes = this.computePointSizes( world,
nObs,
crossfilter, crossfilter,
metadataField, colorAccessor,
categoryField centroidLabel
); );
if (renderCache.sizes !== newSizes) { if (renderCache.flags !== newFlags) {
/* update our cache & GL if the buffer changes */ renderCache.flags = newFlags;
renderCache.size = newSizes; flagBuffer({ data: newFlags, dimension: 1 });
sizeBuffer({ data: newSizes, dimension: 1 }); needsRepaint = true;
} }
this.count = nObs; this.count = nObs;
regl._refresh(); if (needsRepaint) {
this.reglDraw( this.renderPoints(
regl, regl,
drawPoints, drawPoints,
sizeBuffer, colorBuffer,
colorBuffer, pointBuffer,
pointBuffer, flagBuffer,
camera camera,
); projectionTF
);
}
} }
const createToolSVG = () => { const createToolSVG = () => {
@@ -311,7 +382,8 @@ class Graph extends React.Component {
stateChanges = { ...stateChanges, centroidSVG: newCentroidSVG }; 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 ( if (
prevProps.responsive.height !== responsive.height || prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width 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) { brushToolUpdate(tool, container) {
/* /*
this is called from componentDidUpdate(), so be very careful using 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) { mapScreenToPoint(pin) {
/* /*
Map an XY coordinates from screen domain to cell/point range, Map an XY coordinates from screen domain to cell/point range,
accounting for current pan/zoom camera. accounting for current pan/zoom camera.
*/ */
const { responsive } = this.props; const { responsive } = this.props;
const { regl, camera, transform } = this.state; const { camera, projectionTF, modelInvTF } = this.state;
const { glScaleX, glScaleY } = transform; const cameraInvTF = camera.invView();
const gl = regl._gl; /* screen -> 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
const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1; const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 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; return xy;
} }
@@ -511,29 +540,21 @@ class Graph extends React.Component {
Map an XY coordinate from cell/point domain to screen range. Inverse Map an XY coordinate from cell/point domain to screen range. Inverse
of mapScreenToPoint() of mapScreenToPoint()
*/ */
const { responsive } = this.props; const { responsive } = this.props;
const { regl, camera, transform } = this.state; const { camera, projectionTF, modelTF } = this.state;
const { glScaleX, glScaleY } = transform; const cameraTF = camera.view();
const gl = regl._gl; const xy = vec2.transformMat3(vec2.create(), xyCell, modelTF);
vec2.transformMat3(xy, xy, cameraTF);
// get aspect ratio vec2.transformMat3(xy, xy, projectionTF);
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 pin = [ const pin = [
Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2),
Math.round( 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; 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() { render() {
const { responsive, graphInteractionMode } = this.props; const { responsive } = this.props;
return ( return (
<div id="graphWrapper"> <div id="graphWrapper">
@@ -676,6 +750,11 @@ class Graph extends React.Component {
ref={canvas => { ref={canvas => {
this.reglCanvas = canvas; this.reglCanvas = canvas;
}} }}
onMouseDown={this.handleCanvasEvent}
onMouseUp={this.handleCanvasEvent}
onMouseMove={this.handleCanvasEvent}
onDoubleClick={this.handleCanvasEvent}
onWheel={this.handleCanvasEvent}
/> />
</div> </div>
</div> </div>
@@ -1,44 +1,62 @@
// jshint esversion: 6 import { glPointFlags } from "../../util/glHelpers";
const mat4 = require("gl-mat4");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
export default function(regl) { export default function(regl) {
return regl({ return regl({
vert: ` vert: `
precision mediump float; precision mediump float;
attribute vec2 position; attribute vec2 position;
attribute vec3 color; attribute vec3 color;
attribute float size; attribute float flag;
uniform float distance;
uniform mat4 projection, view; uniform mat3 projection;
varying vec3 fragColor; 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() { void main() {
gl_PointSize = 7.0 / pow(distance, 2.5) + size; bool isNaN, isSelected, isHighlight;
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1); getFlags(flag, isNaN, isSelected, isHighlight);
fragColor = color;
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: ` frag: `
precision mediump float; precision mediump float;
varying vec3 fragColor; varying vec4 fragColor;
void main() { void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) { if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard; discard;
} }
gl_FragColor = vec4(fragColor, 1); gl_FragColor = fragColor;
}`, }`,
attributes: { attributes: {
position: regl.prop("position"), position: regl.prop("position"),
color: regl.prop("color"), color: regl.prop("color"),
size: regl.prop("size") flag: regl.prop("flag")
}, },
uniforms: { uniforms: {
distance: regl.prop("distance"), projection: regl.prop("projection"),
view: regl.prop("view"), nPoints: regl.prop("nPoints"),
projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000) minViewportDimension: regl.prop("minViewportDimension")
}, },
count: regl.prop("count"), count: regl.prop("count"),
+172 -129
View File
@@ -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 React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Button, ButtonGroup } from "@blueprintjs/core"; import { Button, ButtonGroup } from "@blueprintjs/core";
import _regl from "regl"; import _regl from "regl";
import * as d3 from "d3"; 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 * as globals from "../../globals";
import _camera from "../../util/camera";
import setupScatterplot from "./setupScatterplot"; import setupScatterplot from "./setupScatterplot";
import styles from "./scatterplot.css"; import styles from "./scatterplot.css";
import _drawPoints from "./drawPointsRegl"; import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear";
import { margin, width, height } from "./util"; import { margin, width, height } from "./util";
import finiteExtent from "../../util/finiteExtent"; 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 => { @connect(state => {
const { world, crossfilter } = state; const { world, crossfilter, universe } = state;
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls; const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
const expressionX = const expressionX =
world &&
scatterplotXXaccessor && scatterplotXXaccessor &&
world.varData.hasCol(scatterplotXXaccessor) world.varData.col(scatterplotXXaccessor)?.asArray();
? world.varData.col(scatterplotXXaccessor).asArray()
: null;
const expressionY = const expressionY =
world &&
scatterplotYYaccessor && scatterplotYYaccessor &&
world.varData.hasCol(scatterplotYYaccessor) world.varData.col(scatterplotYYaccessor)?.asArray();
? world.varData.col(scatterplotYYaccessor).asArray()
: null;
return { return {
world, world,
universe,
colorRGB: state.colors.rgb, colorRGB: state.colors.rgb,
colorScale: state.colors.scale, colorScale: state.colors.scale,
@@ -57,6 +54,64 @@ import finiteExtent from "../../util/finiteExtent";
}; };
}) })
class Scatterplot extends React.Component { 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) { constructor(props) {
super(props); super(props);
this.count = 0; this.count = 0;
@@ -64,7 +119,7 @@ class Scatterplot extends React.Component {
this.renderCache = { this.renderCache = {
positions: null, positions: null,
colors: null, colors: null,
sizes: null, flags: null,
xScale: null, xScale: null,
yScale: null yScale: null
}; };
@@ -85,39 +140,37 @@ class Scatterplot extends React.Component {
this.renderCache = { ...this.renderCache, ...scales }; this.renderCache = { ...this.renderCache, ...scales };
} }
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
const regl = _regl(this.reglCanvas); const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl); const drawPoints = _drawPoints(regl);
// Create render transform
const projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
// preallocate buffers // preallocate buffers
const pointBuffer = regl.buffer(); const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer(); const colorBuffer = regl.buffer();
const sizeBuffer = regl.buffer(); const flagBuffer = regl.buffer();
const reglRender = regl.frame(() => { this.renderPoints(
this.reglDraw( regl,
regl, drawPoints,
drawPoints, flagBuffer,
sizeBuffer, colorBuffer,
colorBuffer, pointBuffer,
pointBuffer, projectionTF
camera );
);
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({ this.setState({
regl, regl,
sizeBuffer, flagBuffer,
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
svg, svg,
reglRender, drawPoints,
camera, projectionTF
drawPoints
}); });
} }
@@ -129,22 +182,22 @@ class Scatterplot extends React.Component {
scatterplotYYaccessor, scatterplotYYaccessor,
expressionX, expressionX,
expressionY, expressionY,
colorRGB colorRGB,
colorAccessor
} = this.props; } = this.props;
const { const {
reglRender,
regl, regl,
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
sizeBuffer, flagBuffer,
svg, svg,
drawPoints, drawPoints,
camera projectionTF
} = this.state; } = this.state;
if ( if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc scatterplotXXaccessor !== prevProps.scatterplotXXaccessor ||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc scatterplotYYaccessor !== prevProps.scatterplotYYaccessor ||
world !== prevProps.world // shape or clip of world changed world !== prevProps.world // shape or clip of world changed
) { ) {
const scales = Scatterplot.setupScales(expressionX, expressionY); const scales = Scatterplot.setupScales(expressionX, expressionY);
@@ -152,76 +205,54 @@ class Scatterplot extends React.Component {
this.renderCache = { ...this.renderCache, ...scales }; this.renderCache = { ...this.renderCache, ...scales };
} }
if (reglRender && this.reglRenderState === "rendering") { if (world && regl) {
reglRender.cancel();
this.reglRenderState = "paused";
}
if (
world &&
regl &&
pointBuffer &&
colorBuffer &&
sizeBuffer &&
expressionX &&
expressionY &&
scatterplotXXaccessor &&
scatterplotYYaccessor
) {
const { renderCache } = this; const { renderCache } = this;
const { xScale, yScale } = this.renderCache; const { xScale, yScale } = this.renderCache;
const cellCount = expressionX.length; let needsRepaint = false;
// Points change when expressionX or expressionY change. const newPositions = this.computePointPositions(
if ( expressionX,
!renderCache.positions || expressionY,
expressionX !== prevProps.expressionX || xScale,
expressionY !== prevProps.expressionY yScale
) {
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
); );
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) { drawAxesSVG(xScale, yScale, svg) {
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props; const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
svg.selectAll("*").remove(); svg.selectAll("*").remove();
@@ -307,6 +322,34 @@ class Scatterplot extends React.Component {
.text(scatterplotXXaccessor); .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() { render() {
const { dispatch } = this.props; const { dispatch } = this.props;
const { minimized } = this.state; const { minimized } = this.state;
@@ -367,7 +410,7 @@ class Scatterplot extends React.Component {
height={height} height={height}
data-testid="scatterplot" data-testid="scatterplot"
style={{ style={{
marginLeft: margin.left - 7, marginLeft: margin.left,
marginTop: margin.top marginTop: margin.top
}} }}
ref={canvas => { ref={canvas => {
+141 -51
View File
@@ -1,66 +1,156 @@
// jshint esversion: 6 import { vec2, mat3 } from "gl-matrix";
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");
const panSpeed = 1.0; // changed from 0.4 to 1.0 per issue #722
const scaleSpeed = 0.5; const scaleSpeed = 0.5;
const scaleMax = 3; const scaleMax = 3.0;
const scaleMin = 1.03; const scaleMin = 0.5;
function attachCamera(canvas, opts) { // private
opts = opts || {}; const scratch0 = new Float32Array(16);
opts.pan = opts.pan !== false; const scratch1 = new Float32Array(16);
opts.scale = opts.scale !== false;
opts.rotate = opts.rotate !== false;
const scroll = createScroll(canvas, opts.scale); class Camera {
const mbut = mb(canvas, opts.rotate); constructor(canvas) {
const mpos = mp(canvas); this.prevEvent = {
const camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]); 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() { distance() {
const ctrl = key("<control>") || key("<alt>"); return this.viewMatrix[0];
const alt = key("<shift>"); }
const { height, width } = canvas;
if (opts.rotate && mbut.left && ctrl && !alt) { pan(dx, dy) {
camera.rotate( mat3.translate(this.viewMatrix, this.viewMatrix, [dx, dy]);
[mpos.x / width - 0.5, mpos.y / height - 0.5], mat3.invert(this.viewMatrixInv, this.viewMatrix);
[mpos.prevX / width - 0.5, mpos.prevY / height - 0.5] }
);
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)) { mousePan(e, projectionTF) {
camera.pan([ const projectionInvTF = mat3.invert(scratch0, projectionTF);
((panSpeed * (mpos[0] - mpos.prev[0])) / width) * camera.distance, const pos = this.localPosition(
((panSpeed * (mpos[1] - mpos.prev[1])) / height) * camera.distance 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;
} }
return viewChanged;
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();
} }
} }
function attachCamera(canvas) {
return new Camera(canvas);
}
export default attachCamera; export default attachCamera;
+44
View File
@@ -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);
}
`;