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: [
{
"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: {
+4 -103
View File
@@ -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
View File
@@ -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",
+35 -29
View File
@@ -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"),
+274 -195
View File
@@ -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"),
+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 { 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
View File
@@ -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;
+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);
}
`;