#D3Thursday is back again with a one-off post. This time we will be looking at how we can use the popular 3D graphics library Three.js to create a 3D residual plot!
In this post we will take a look at how we can turn a list of coordinates into a 3D surface and how we can use 2D text elements in our 3D visualization to create tick values.
Our 3D residual plot takes 4 inputs: the first covariate (x-dimension), the second covariate (z-dimension), the predicted value for a given covariate pairing (z-dimension), and the residual for a given covariate pairing (z-dimension). How then do we take a series of (x, y, z) coordinates and create a surface from them? The standard way to do this is by creating a series of 3 triangles connecting points in 3-dimensional space. In order to do this we will assume all values are defined for the 2D matrix formed by covariate 1 and covariate 2. In other words, if the values of covariate 1 are [1, 2, 3] and the values of covariate 2 are [4, 5, 6] then there must be a predicted value for all 9 pairings of covairate 1 and covariate 2. If this assumption is met, creating a surface from our data set becomes trivial.
let index, geometry, mesh;
GEOMETRIES = [];
for (let i = 0; i < COV2.length - 1; i++) {
for (let j = 0; j < COV1.length - 1; j++) {
index = i * COV1.length + j;
for (let k = 0; k < 2; k++) {
// Create geometry
geometry = new THREE.Geometry();
if (k) {
geometry.vertices.push(
new THREE.Vector3(
COV1_SCALE(DATA[index + COV1.length + 1].cov1),
RESULT_SCALE(DATA[index + COV1.length + 1].pred),
COV2_SCALE(DATA[index + COV1.length + 1].cov2)
)
);
} else {
geometry.vertices.push(
new THREE.Vector3(
COV1_SCALE(DATA[index].cov1),
RESULT_SCALE(DATA[index].pred),
COV2_SCALE(DATA[index].cov2)
)
);
}
geometry.vertices.push(
new THREE.Vector3(
COV1_SCALE(DATA[index + 1].cov1),
RESULT_SCALE(DATA[index + 1].pred),
COV2_SCALE(DATA[index + 1].cov2)
)
);
geometry.vertices.push(
new THREE.Vector3(
COV1_SCALE(DATA[index + COV1.length].cov1),
RESULT_SCALE(DATA[index + COV1.length].pred),
COV2_SCALE(DATA[index + COV1.length].cov2)
)
);
geometry.faces.push(new THREE.Face3(0, 1, 2));
geometry.computeFaceNormals();
// Create mesh from custom geometry and add to scene
mesh = new THREE.Mesh(geometry, PREDICTION_MATERIAL);
GEOMETRIES.push(mesh);
SCENE.add(mesh);
}
}
}
In order to create a triangle geometry there are a few steps:
It should be noted that we are using a double sided material here so the order of the vertices doesn't matter. If you wanted to create a single sided material, then the order of the vertices in the face would determine the direction of the face normal and which side is lit.
There are several methods used to integrate text in Three.js, each with it's own trade offs. You could import a model of a 3D text object, create your own 3D model of a text object in Three.js, create a custom texture with your text on it, or use bitmap fonts mapped to buffer geometries. For this post, we will look at a simpler method than any of these. We will use standard HTML div elements overlayed on our Three.js canvas to create the illusion of 2D text elements in our 3D space.
// Append axes values
d3.select("body")
.selectAll(".x-axis-text")
.data(COV1_TICKS)
.enter()
.append("div")
.classed("axis-text x-axis-text", true)
.html(function(d) {
return Math.round(d * 100) / 100;
})
.each(function(d) {
this.position = new THREE.Vector3(COV1_SCALE(d), -0.53, +0.5);
})
.each(setXY);
...
// Render loop function
function render() {
...
// Update axis text positions
d3.selectAll(".axis-text")
.each(setXY);
}
...
// Set XY values of element in window given XYZ in world coordinates
function setXY() {
const vector = this.position.clone();
const canvas = RENDERER.domElement;
vector.project(CAMERA);
vector.x = Math.round(
(0.5 + vector.x / 2) * (canvas.width / window.devicePixelRatio)
);
vector.y = Math.round(
(0.5 - vector.y / 2) * (canvas.height / window.devicePixelRatio)
);
d3.select(this)
.style("top", vector.y + "px")
.style("left", vector.x + "px");
}
The only challenge in using div elements for the text elements in our Three.js visualization is mapping a given position in world coordinates (x, y, z) to screen coordinates (x, y). Luckily, Three.js makes the mathematics of this projection simple by providing the project method. This allows you to quickly convert from 3D space into a 2D coordinate system. All that's left after that is scaling the values output from the project method to the pixel size of your display. There is one problem with the result of the above code, however.
Because the axis text elements are rendered as div elements on top of the canvas the text elements don't disappear of they are obscured by the prediction surface or if the camera has zoomed past them. We can remedy this problem by toggling the display property on the divs depending on the camera position and whether not they are obscured from view.
// Update axis text positions
d3.selectAll(".axis-text")
.style("display", setDisplay)
.each(setXY);
...
// Determine whether to display text based on text position and camera position
function setDisplay() {
const vector = this.position.clone();
const camToText = CAMERA.position.distanceTo(vector);
vector.project(CAMERA);
// Update text raycaster with new camera location
TEXT_RAYCASTER.setFromCamera(vector, CAMERA);
// Get intersections from raycaster
const intersects = TEXT_RAYCASTER.intersectObjects(GEOMETRIES);
let intersected = false;
for (let i = 0; i < intersects.length; i++) {
if (intersects[i].distance < camToText) {
intersected = true;
break;
}
}
return intersected || CAMERA.position.length() < this.position.length()
? "none"
: "initial";
}
This method isn't perfect, since it only checks if the origin position of the text element is obscured, not every position the element occupies. This solution is, however, highly performant and simpler than the integration of 3D text elements. For more information on some of the other methods of creating text elements in your Three.js project check out the link in the additional resources section below.
This post was a one-off, but in the coming weeks #D3Thursday will return with a new series of posts with new charts focusing on the challenge of visualizing clinical trials data! Stay posted for additional #D3Thursday content and be sure to reach out with any ideas you have for visualizations or interesting data you think deserve a post!
Remember to follow these articles on the SAS Communities Library and the #D3Thursday Twitter hashtag. Comment below or Tweet your questions and comments!
Hi Arne,
Thanks for reaching out. A technical consultant from your local SAS office is reaching out to assist you in using your own data in this visualization.
Ryan
SAS Innovate 2025 is scheduled for May 6-9 in Orlando, FL. Sign up to be first to learn about the agenda and registration!
Data Literacy is for all, even absolute beginners. Jump on board with this free e-learning and boost your career prospects.