3D Node Link
Basic example of a 3D node-link diagram simulated using d3-force-3d of Les Misérables character co-occurrences.
js
// SPDX-License-Identifier: Apache-2.0
// Copyright : J.P. Morgan Chase & Co.
import * as anu from '@jpmorganchase/anu';
import * as BABYLON from '@babylonjs/core';
import * as d3 from 'd3';
import * as d3force from 'd3-force-3d'; //External required dependency for force layouts
import leMis from './data/miserables.json' assert {type: 'json'};
//Create and export a function that takes a Babylon engine and returns a Babylon Scene
export function nodelink3d(engine) {
//Create an empty Scene
const scene = new BABYLON.Scene(engine);
//Add some lighting
let light = new BABYLON.HemisphericLight('light1', new BABYLON.Vector3(0, 10, 0), scene);
light.diffuse = new BABYLON.Color3(1, 1, 1);
light.specular = new BABYLON.Color3(1, 1, 1);
light.groundColor = new BABYLON.Color3(1, 1, 1);
//Add a camera that rotates around the origin and adjust its properties
const camera = new BABYLON.ArcRotateCamera('Camera', 0, 0, 0, new BABYLON.Vector3(0, 0, 0), scene);
camera.position = new BABYLON.Vector3(1, 1.5, -3);
camera.wheelPrecision = 20;
camera.minZ = 0;
camera.attachControl(true);
//Create a D3 color scale using Anu helper functions to map values to Color4 objects with colors based on the 'schemecategory10' palette from D3
let scaleC = d3.scaleOrdinal(anu.ordinalChromatic('d310').toColor4());
//Create a D3 simulation with several forces, this function mutates the data object we pass in so we make a deep clone of it first
let data = JSON.parse(JSON.stringify(leMis));
let simulation = d3force.forceSimulation(data.nodes, 3)
.force('link', d3force.forceLink(data.links))
.force('charge', d3force.forceManyBody())
.force('collide', d3force.forceCollide())
.force('center', d3force.forceCenter(0, 0, 0))
.on('tick', ticked)
.on('end', () => simulation.stop());
//We use Mesh instancing here for better performance, first we create a Mesh that serves as the root Node
let rootSphere = anu.create('sphere', 'node', { diameter: 5 });
rootSphere.isVisible = false;
rootSphere.registerInstancedBuffer('color', 4); //We need an InstancedBuffer to set the color of instances
//Create a Center of Transform TransformNode that serves the parent node for all our meshes that make up our chart
let CoT = anu.create('cot', 'cot');
//Select our CoT so that we have it as a Selection object
let network = anu.selectName('cot', scene);
//Create instanced sphere meshes from our rootSphere as children of our CoT for each row of our data and set their initial visual encodings using method chaining
let nodes = network.bindInstance(rootSphere, data.nodes)
.position((d) => new BABYLON.Vector3(d.x, d.y, d.z))
.setInstancedBuffer('color', (d) => scaleC(d.group))
.id((d) => d.id);
//Create a helper function that will return us an array of arrays where each sub-array is the start and end Vector3 of each link
function dataToLinks(data) {
let lines = [];
data.forEach((v, i) => {
let start = (new BABYLON.Vector3(v.source.x, v.source.y, v.source.z));
let end = (new BABYLON.Vector3(v.target.x, v.target.y, v.target.z));
lines.push([start, end]);
})
return lines;
}
//Create our lineSystem mesh using our data and helper function from above
let links = network.bind('lineSystem', { lines: (d) => dataToLinks(d), updatable: true }, [data.links])
.prop('color', new BABYLON.Color4(1, 1, 1, 1));
//Update the position of the nodes and links each time the simulation ticks
function ticked() {
//For the instanced spheres just set a new position
nodes.position((d) => (new BABYLON.Vector3(d.x, d.y, d.z)));
//For the links use the run method to replace the lineSystem mesh with a new one, passing in the mesh into the instance option
links.run((d, n, i) => anu.create('lineSystem', 'edge', { lines: dataToLinks(d), instance: n, updatable: true }, d));
}
//The network can get quite big in size (spatial size), so here we run a function to scale the entire network down to a 1x1x1 box
network.run((d,n,i) => n.normalizeToUnitCube());
return scene;
}