UNPKG

@jonobr1/force-directed-graph

Version:

GPU supercharged attraction-graph visualizations for the web built on top of Three.js

661 lines (581 loc) 18.3 kB
import { Color, Group, RepeatWrapping, Vector2, Vector3 } from 'three'; import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js'; import { clamp, each, getPotSize, rgbToIndex } from './math.js'; import simulation from './shaders/simulation.js'; import { Points } from './points.js'; import { Links } from './links.js'; import { Registry } from './registry.js'; import { Hit } from './hit.js'; const color = new Color(); const position = new Vector3(); const size = new Vector2(); const buffers = { int: new Uint8ClampedArray(4), float: new Float32Array(4), }; class ForceDirectedGraph extends Group { ready = false; /** * @param {THREE.WebGLRenderer} renderer - the three.js renderer referenced to create the render targets * @param {Object} [data] - optional data to automatically set the data of the graph */ constructor(renderer, data) { super(); this.userData.registry = new Registry(); this.userData.renderer = renderer; this.userData.uniforms = { decay: { value: 1 }, alpha: { value: 1 }, is2D: { value: false }, time: { value: 0 }, size: { value: 64 }, maxSpeed: { value: 10 }, timeStep: { value: 1 }, damping: { value: 0.7 }, repulsion: { value: -0.3 }, springLength: { value: 2 }, stiffness: { value: 0.1 }, gravity: { value: 0.1 }, nodeRadius: { value: 1 }, nodeScale: { value: 8 }, sizeAttenuation: { value: true }, frustumSize: { value: 100 }, linksInheritColor: { value: false }, pointsInheritColor: { value: true }, pointColor: { value: new Color(1, 1, 1) }, linkColor: { value: new Color(1, 1, 1) }, opacity: { value: 1 }, }; this.userData.hit = new Hit(this); if (data) { this.set(data); } } static getPotSize = getPotSize; static Properties = [ 'decay', 'alpha', 'is2D', 'time', 'size', 'maxSpeed', 'timeStep', 'damping', 'repulsion', 'springLength', 'stiffness', 'gravity', 'nodeRadius', 'nodeScale', 'sizeAttenuation', 'frustumSize', 'linksInheritColor', 'pointsInheritColor', 'pointColor', 'linkColor', 'opacity', 'blending', ]; /** * @param {Object} data - Object with nodes and links properties based on https://observablehq.com/@d3/force-directed-graph-component * @param {Function} callback * @description Set the data to an instance of force directed graph. Because of the potential large amount of data this function runs on a request animation frame and returns a promise (or a passed callback) to give indication when the graph is ready to be rendered. * @returns {Promise} */ set(data, callback) { const scope = this; let { gpgpu, registry, renderer, uniforms } = this.userData; this.ready = false; this.userData.data = data; // Reset all properties registry.clear(); // Dispose of all previous gpgpu data if (gpgpu) { for (let i = 0; i < gpgpu.variables.length; i++) { const variable = gpgpu.variables[i]; for (let j = 0; j < variable.renderTargets.length; j++) { const renderTarget = variable.renderTargets[j]; renderTarget.dispose(); } variable.initialValueTexture.dispose(); variable.material.dispose(); } } // Reset points and links for (let i = 0; i < this.children.length; i++) { const child = this.children[i]; this.remove(child); if ('dispose' in child) { child.dispose(); } } // Initialize new properties const size = getPotSize(Math.max(data.nodes.length, data.links.length)); uniforms.size.value = size; gpgpu = new GPUComputationRenderer(size, size, renderer); const textures = { positions: gpgpu.createTexture(), velocities: gpgpu.createTexture(), links: gpgpu.createTexture(), }; const variables = { positions: gpgpu.addVariable( 'texturePositions', simulation.positions, textures.positions ), velocities: gpgpu.addVariable( 'textureVelocities', simulation.velocities, textures.velocities ), }; this.userData.gpgpu = gpgpu; this.userData.variables = variables; return ( register() .then(fill) // TODO: Add a sort here for future simulation methods .then(setup) .then(generate) .then(complete) .catch((error) => { console.warn('Force Directed Graph:', error); }) ); function register() { return each(data.nodes, (node, i) => { registry.set(i, node); }); } function fill() { let k = 0; return each( textures.positions.image.data, (_, i) => { const x = Math.random() * 2 - 1; const y = Math.random() * 2 - 1; const z = Math.random() * 2 - 1; if (k < data.nodes.length) { const node = data.nodes[k]; textures.positions.image.data[i + 0] = typeof node.x !== 'undefined' ? node.x : x; textures.positions.image.data[i + 1] = typeof node.y !== 'undefined' ? node.y : y; textures.positions.image.data[i + 2] = typeof node.z !== 'undefined' ? node.z : z; textures.positions.image.data[i + 3] = node.isStatic ? 1 : 0; } else { // Throw all outside "extraneous" nodes generated by texture far far away. textures.positions.image.data[i + 0] = uniforms.frustumSize.value * 10; textures.positions.image.data[i + 1] = uniforms.frustumSize.value * 10; textures.positions.image.data[i + 2] = uniforms.frustumSize.value * 10; textures.positions.image.data[i + 3] = uniforms.frustumSize.value * 10; } let i1, i2, uvx, uvy; if (k < data.links.length) { // Calculate uv look up for edge calculations i1 = registry.get(data.links[k].source); i2 = registry.get(data.links[k].target); data.links[k].sourceIndex = i1; data.links[k].targetIndex = i2; uvx = (i1 % size) / size; uvy = Math.floor(i1 / size) / size; textures.links.image.data[i + 0] = uvx; textures.links.image.data[i + 1] = uvy; uvx = (i2 % size) / size; uvy = Math.floor(i2 / size) / size; textures.links.image.data[i + 2] = uvx; textures.links.image.data[i + 3] = uvy; } k++; }, 4 ); } function setup() { return new Promise((resolve, reject) => { gpgpu.setVariableDependencies(variables.positions, [ variables.positions, variables.velocities, ]); gpgpu.setVariableDependencies(variables.velocities, [ variables.velocities, variables.positions, ]); variables.positions.material.uniforms.is2D = uniforms.is2D; variables.positions.material.uniforms.timeStep = uniforms.timeStep; variables.velocities.material.uniforms.alpha = uniforms.alpha; variables.velocities.material.uniforms.is2D = uniforms.is2D; variables.velocities.material.uniforms.size = uniforms.size; variables.velocities.material.uniforms.time = uniforms.time; variables.velocities.material.uniforms.nodeRadius = uniforms.nodeRadius; variables.velocities.material.uniforms.nodeAmount = { value: data.nodes.length, }; variables.velocities.material.uniforms.edgeAmount = { value: data.links.length, }; variables.velocities.material.uniforms.maxSpeed = uniforms.maxSpeed; variables.velocities.material.uniforms.timeStep = uniforms.timeStep; variables.velocities.material.uniforms.damping = uniforms.damping; variables.velocities.material.uniforms.repulsion = uniforms.repulsion; variables.velocities.material.uniforms.textureLinks = { value: textures.links, }; variables.velocities.material.uniforms.springLength = uniforms.springLength; variables.velocities.material.uniforms.stiffness = uniforms.stiffness; variables.velocities.material.uniforms.gravity = uniforms.gravity; variables.positions.wrapS = variables.positions.wrapT = RepeatWrapping; variables.velocities.wrapS = variables.velocities.wrapT = RepeatWrapping; const error = gpgpu.init(); if (error) { reject(error); } else { resolve(); } }); } function generate() { let points; return Points.parse(size, data) .then((geometry) => { points = new Points(geometry, uniforms); }) .then(() => Links.parse(points, data)) .then((geometry) => { const links = new Links(geometry, uniforms); scope.add(points, links); points.renderOrder = links.renderOrder + 1; scope.userData.hit.inherit(points); }); } function complete() { scope.ready = true; if (callback) { callback(); } } } /** * @param {Number} time * @description Function to update the instance meant to be run before three.js's renderer.render method. * @returns {Void} */ update(time) { if (!this.ready) { return this; } const { gpgpu, variables, uniforms } = this.userData; uniforms.alpha.value *= uniforms.decay.value; variables.velocities.material.uniforms.time.value = time / 1000; gpgpu.compute(); const texture = this.getTexture('positions'); for (let i = 0; i < this.children.length; i++) { const child = this.children[i]; child.material.uniforms.texturePositions.value = texture; } return this; } /** * @param {THREE.Vector2} pointer - x, y values normalized to the camera's clipspace * @param {THREE.Camera} camera - the camera to reference ray casting matrices * @description Check to see if a point in the browser's screenspace intersects with any points in the force directed graph. If none found, then null is returned. * @returns {Object|Null} */ intersect(pointer, camera) { const { hit, renderer } = this.userData; renderer.getSize(size); hit.setSize(size.x, size.y); hit.compute(renderer, camera); const x = hit.ratio * size.x * clamp(pointer.x, 0, 1); const y = hit.ratio * size.y * (1 - clamp(pointer.y, 0, 1)); renderer.readRenderTargetPixels( hit.renderTarget, x - 0.5, y - 0.5, 1, 1, buffers.int ); const [r, g, b, a] = buffers.int; const z = 0; const w = 255; const isBlack = r === z && g === z && b === z && a === z; const isWhite = r === w && g === w && b === w && a === w; if (isBlack || isWhite) { return null; } const index = rgbToIndex({ r, g, b }) - 1; const point = this.getPositionFromIndex(index); return { point, data: this.userData.data.nodes[index], }; } getTexture(name) { const { gpgpu, variables } = this.userData; return gpgpu.getCurrentRenderTarget(variables[name]).texture; } getPositionFromIndex(i) { const { points, size } = this; const { gpgpu, renderer, variables } = this.userData; if (!points || !renderer || !size) { console.warn( 'Force Directed Graph:', 'unable to calculate position without points or renderer.' ); return; } const index = i * 3; const uvs = points.geometry.attributes.position.array; const uvx = Math.floor(uvs[index + 0] * size); const uvy = Math.floor(uvs[index + 1] * size); const renderTarget = gpgpu.getCurrentRenderTarget(variables.positions); renderer.readRenderTargetPixels( renderTarget, uvx, uvy, 1, 1, buffers.float ); const [x, y, z] = buffers.float; position.set(x, y, z); return position; } setPointColorById(id, css) { const index = this.getIndexById(id); this.setPointColorFromIndex(index, css); } setPointColorFromIndex(index, css) { const attribute = this.points.geometry.getAttribute('color'); const colors = attribute.array; color.set(css); colors[3 * index + 0] = color.r; colors[3 * index + 1] = color.g; colors[3 * index + 2] = color.b; attribute.needsUpdate = true; } updateLinksColors() { const { data } = this.userData; const ref = this.points.geometry.attributes.color.array; const attribute = this.links.geometry.getAttribute('color'); const colors = attribute.array; return each(data.links, (_, i) => { const l = data.links[i]; const li = i * 6; const si = 3 * l.sourceIndex; const ti = 3 * l.targetIndex; colors[li + 0] = ref[si + 0]; colors[li + 1] = ref[si + 1]; colors[li + 2] = ref[si + 2]; colors[li + 3] = ref[ti + 0]; colors[li + 4] = ref[ti + 1]; colors[li + 5] = ref[ti + 2]; }).then(() => (attribute.needsUpdate = true)); } getIndexById(id) { const { registry } = this.userData; return registry.get(id); } getLinksById(id) { const { data } = this.userData; const index = this.getIndexById(id); const result = []; const promise = each(data.links, (link) => { const { sourceIndex, targetIndex } = link; if (sourceIndex === index || targetIndex === index) { result.push(link); } }); return promise.then(() => result); } getPointById(id) { const { data } = this.userData; const index = this.getIndexById(id); return data.nodes[index]; } dispose() { const { gpgpu } = this.userData; if (gpgpu) { for (let i = 0; i < gpgpu.variables.length; i++) { const variable = gpgpu.variables[i]; variable.material.dispose(); variable.initialValueTexture.dispose(); for (let j = 0; j < variable.renderTargets.length; j++) { const target = variable.renderTargets[j]; target.dispose(); } } } this.userData = {}; return this; } // Getters / Setters get decay() { return this.userData.uniforms.decay.value; } set decay(v) { this.userData.uniforms.decay.value = v; } get alpha() { return this.userData.uniforms.alpha.value; } set alpha(v) { this.userData.uniforms.alpha.value = v; } get is2D() { return this.userData.uniforms.is2D.value; } set is2D(v) { this.userData.uniforms.is2D.value = v; } get time() { return this.userData.uniforms.time.value; } set time(v) { this.userData.uniforms.time.value = v; } get size() { return this.userData.uniforms.size.value; } set size(v) { this.userData.uniforms.size.value = v; } get maxSpeed() { return this.userData.uniforms.maxSpeed.value; } set maxSpeed(v) { this.userData.uniforms.maxSpeed.value = v; } get timeStep() { return this.userData.uniforms.timeStep.value; } set timeStep(v) { this.userData.uniforms.timeStep.value = v; } get damping() { return this.userData.uniforms.damping.value; } set damping(v) { this.userData.uniforms.damping.value = v; } get repulsion() { return this.userData.uniforms.repulsion.value; } set repulsion(v) { this.userData.uniforms.repulsion.value = v; } get springLength() { return this.userData.uniforms.springLength.value; } set springLength(v) { this.userData.uniforms.springLength.value = v; } get stiffness() { return this.userData.uniforms.stiffness.value; } set stiffness(v) { this.userData.uniforms.stiffness.value = v; } get gravity() { return this.userData.uniforms.gravity.value; } set gravity(v) { this.userData.uniforms.gravity.value = v; } get nodeRadius() { return this.userData.uniforms.nodeRadius.value; } set nodeRadius(v) { this.userData.uniforms.nodeRadius.value = v; } get nodeScale() { return this.userData.uniforms.nodeScale.value; } set nodeScale(v) { this.userData.uniforms.nodeScale.value = v; } get sizeAttenuation() { return this.userData.uniforms.sizeAttenuation.value; } set sizeAttenuation(v) { this.userData.uniforms.sizeAttenuation.value = v; } get frustumSize() { return this.userData.uniforms.frustumSize.value; } set frustumSize(v) { this.userData.uniforms.frustumSize.value = v; } get linksInheritColor() { return this.userData.uniforms.linksInheritColor.value; } set linksInheritColor(v) { this.userData.uniforms.linksInheritColor.value = v; } get pointsInheritColor() { return this.userData.uniforms.pointsInheritColor.value; } set pointsInheritColor(v) { this.userData.uniforms.pointsInheritColor.value = v; } get pointColor() { return this.userData.uniforms.pointColor.value; } set pointColor(v) { this.userData.uniforms.pointColor.value = v; } get linksColor() { return this.linkColor; } set linksColor(v) { this.linkColor = v; } get linkColor() { return this.userData.uniforms.linkColor.value; } set linkColor(v) { this.userData.uniforms.linkColor.value = v; } get opacity() { return this.userData.uniforms.opacity.value; } set opacity(v) { this.userData.uniforms.opacity.value = v; } get blending() { return this.children[0].material.blending; } set blending(v) { for (let i = 0; i < this.children.length; i++) { const child = this.children[i]; child.material.blending = v; } } get points() { return this.children[0]; } get links() { return this.children[1]; } get uniforms() { return this.userData.uniforms; } get nodeCount() { const { variables } = this.userData; return variables.velocities.material.uniforms.nodeAmount.value; } get edgeCount() { const { variables } = this.userData; return variables.velocities.material.uniforms.edgeAmount.value; } } export { ForceDirectedGraph };