diff --git a/client/__tests__/e2e/data.js b/client/__tests__/e2e/data.js index 02391376..9a5ab836 100644 --- a/client/__tests__/e2e/data.js +++ b/client/__tests__/e2e/data.js @@ -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: { diff --git a/client/package-lock.json b/client/package-lock.json index 3bf0fa0c..659d8816 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 641eb42c..6eecc0a3 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/graph/drawPointsRegl.js b/client/src/components/graph/drawPointsRegl.js index 8da7e56b..11df03ea 100644 --- a/client/src/components/graph/drawPointsRegl.js +++ b/client/src/components/graph/drawPointsRegl.js @@ -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"), diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index 09cadd13..08818c33 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -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 (