#D3Thursday is back again with a new series of articles over the next few weeks. This time we will be looking at how we can use the popular 3D graphics library Three.js to create a 3D choropleth! Last time we started by creating a 2D canvas choropleth using D3, and then using that choropleth to texture a 3D sphere rendered using Three.js. This week we will expand on that example by adding interactive overlays to highlight countries when they are hovered over or clicked on.
To keep things simple we will be using the same sample data (Sample_Data_16.sas7bdat) as last week for this post.
The first step to creating our overlays is determining what country the user is hovering over. We accomplish this by using a raycaster to determine where on the sphere our cursor is hovering.
// Render loop function
function render() {
// Update controls
CONTROLS.update();
// Update raycaster with new mouse and camera location
RAYCASTER.setFromCamera(MOUSE, CAMERA);
// Get intersections from raycaster with choropleth sphere
const intersect = RAYCASTER.intersectObjects([CHOROPLETH_SPHERE])[0];
COUNTRY = intersect ? getCountry(
SPHERE_GEOMETRY.vertices[intersect.face.a],
SPHERE_GEOMETRY.vertices[intersect.face.b],
SPHERE_GEOMETRY.vertices[intersect.face.c]
) : null;
if (!PRESERVE && COUNTRY && COUNTRY.id != TIP_ID) {
setOverlay();
} else if (!PRESERVE && !COUNTRY && TIP_ID) {
clearOverlay();
}
// Render frame
requestAnimationFrame(render);
RENDERER.render(SCENE, CAMERA);
}
Our raycaster casts a ray from the location of the camera through the current position of the mouse on each frame, and then checks to see if that ray intersects with our choropleth sphere. If the ray intersects our sphere, the raycaster will return the face of our geometry that is being intersected. Then we can determine the country that is associated with the intersected face.
// Get country corresponding to face by iterating over features and finding which feature contains center
function getCountry(a, b, c) {
// Get center of face
const centerXYZ = {
x: (a.x + b.x + c.x) / 3,
y: (a.y + b.y + c.y) / 3,
z: (a.z + b.z + c.z) / 3,
};
// Convert to lat/lng
const centerLatLng = {
lat: Math.asin(centerXYZ.y) * 180 / Math.PI,
lng: Math.atan2(-centerXYZ.z, centerXYZ.x) * 180 / Math.PI
}
// Iterate over all features
let feature;
for (let i = 0; i < WORLD_JSON.features.length; i++) {
feature = WORLD_JSON.features[i];
// Check if center point is in polygon(s) of feature
if (feature.geometry.type == "Polygon") {
if (pointInPoly(centerLatLng, feature.geometry.coordinates[0])) {
return {id: feature.properties.iso_a3, name: feature.properties.name};
}
} else {
for (let j = 0; j < feature.geometry.coordinates.length; j++) {
if (pointInPoly(centerLatLng, feature.geometry.coordinates[j][0])) {
return {id: feature.properties.iso_a3, name: feature.properties.name};
}
}
}
}
return null;
}
We do this by first calculating the center of the face in world coordinates, converting that value to a latitude/longitude value, and then iterating over all the features in our geojson file to see if our center coordinate falls within a country. Here I'm using the even-odd rule to determine if the point in question falls within one of our geojson features. Once we have determined the country being intersected, we can set or clear our overlay if necessary.
// Set overlay values given country corresponding to intersection
function setOverlay() {
// Set overlay
OVERLAY_SPHERE.material.map = getTexture(COUNTRY.id);
// Set tooltip values
TIP_ID = COUNTRY.id;
d3.select(".tooltip-title")
.html(COUNTRY.id + " - " + COUNTRY.name);
d3.select(".tooltip-value")
.html(DATA[COUNTRY.id] ? METADATA.measureFormat(DATA[COUNTRY.id].measure) : "No Data");
}
// Clear overlay
function clearOverlay() {
// Clear overlay
OVERLAY_SPHERE.material.map = getTexture("BLANK");
// Clear tooltip values
TIP_ID = null;
d3.select(".tooltip-title")
.html("");
d3.select(".tooltip-value")
.html("");
}
There are two aspects to our overlays. First, we have a tooltip statically positioned in the upper left of the scene. Second, we have a second sphere placed over our choropleth sphere that is textured with a transparent background and only the intersected country colored in. The result is a 3D interactive overlay that allows the user to see the data value for any country by either hovering or clicking on the country.
As you can imagine, an interactive 3D data visualization like this one requires more horsepower to run than the 2D data visualizations we've created in past posts. In particular, when designing a 3D data visualization like this one, you need to be careful when balancing the trade off between run time performance, load time, and memory usage. If you try to perform too many actions during the render loop, you may wind up dropping frames resulting in choppy animation. If you try to perform too many actions at initialization, you may wind up with an unpleasantly long wait time. If you store too many objects to ease in computation, you may run out of memory and cause a browser crash. In the case of our interactive overlays, the process of determining the country intersected by the raycaster and creating a 3D overlay can quickly lead to a poorly performing application if not implemented properly.
The first and most simple option for creating overlays is to perform all computation at run time, as shown in the snippets above. This means that on every execution of the render loop the program checks the raycaster for intersects, computes the matching country if an intersection occurs, and loads a new texture for a the overlay if necessary. The benefit of this solution is that we are only storing one overlay texture at a time and therefore have a short load time. The problem with this solution is that if multiple countries are hovered on in quick succession, the application can drop frames and become choppy.
One way to solve the problems of loading textures at run time is to store all textures that may be needed when the application is first started. Rather than perform the slow task of creating a new texture for each overlay in the render loop, we can instead just access a stored copy of the texture at run time. This eliminates the problem of dropping frames at run time, but comes with it's own problems. Depending on the number of textures to be stored, this process could create an unacceptably long load time. Additionally, depending on the size of the textures to be loaded, the browser may run out of memory and crash when trying to store them all.
Memoization provides a happy compromise between the two above solutions. Memoization is the process of caching the results of a function when it is called, and then using that cache to quickly return the result for subsequent function calls with the same arguments. Here's a simple memoization function that I used to optimize this visualization:
let TEXTURE_CACHE = memoize(getTexture); // Cached textures for each country
...
// Memoization function
function memoize(fn) {
const cache = {};
return function() {
const args = JSON.stringify(arguments);
if (cache[args] === undefined) {
const val = fn.apply(null, arguments);
cache[args] = val;
return val;
} else {
return cache[args];
}
};
}
Now, by simply replacing all calls to getTexture with TEXTURE_CACHE we can call our memoized function. The result is that we have a short initial load because textures are only loaded one at a time, we have better performance after the textures have been cached, and we only store the textures that the user needs instead of storing all textures. Additionally, we can even put in place logic to clear the cache of the memoized function if it grows too large.
While memoization is a happy middle ground between two extremes, it's important to note that one of the other solutions may be better for your application. Before creating a 3D visualization like this one, I encourage you to think through some of the following questions.
For the next and final post in this series we will go one step further and animate our interactive 3D choropleth over time!
Remember to follow these articles on the SAS Communities Library and the #D3Thursday Twitter hashtag. Comment below or Tweet your questions and comments!
Are you ready for the spotlight? We're accepting content ideas for SAS Innovate 2025 to be held May 6-9 in Orlando, FL. The call is open until September 16. Read more here about why you should contribute and what is in it for you!
Data Literacy is for all, even absolute beginners. Jump on board with this free e-learning and boost your career prospects.