BookmarkSubscribeRSS Feed

Adding Interactivity to a 3D Choropleth in SAS Visual Analytics with #D3Thursday

Started ‎07-04-2019 by
Modified ‎07-05-2019 by
Views 2,504

#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.

 

 

Interactive 3D ChoroplethInteractive 3D Choropleth

To keep things simple we will be using the same sample data (Sample_Data_16.sas7bdat) as last week for this post.

 

Adding Interactive Overlays

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.

 

 

Interactive OverlayInteractive Overlay

Performance Considerations

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.

 

Load Textures at Run Time 

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. 

 

Load Textures at Start

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 

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.

 

  1. Do I need dynamic overlay textures? Tooltips or another alternative may be sufficient for your application.
  2. What client devices/browsers do I need to run on? Different environments will have different performance/memory limitations.
  3. Will load time matter for your application? You may be able to load your application in the background while the user is presented with other material.

 

Additional Resources

Next Post

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!

 

D3Thursday Logo.png

 

 

 

 

 

 

Version history
Last update:
‎07-05-2019 10:48 AM
Updated by:
Contributors

sas-innovate-2024.png

Available on demand!

Missed SAS Innovate Las Vegas? Watch all the action for free! View the keynotes, general sessions and 22 breakouts on demand.

 

Register now!

Free course: Data Literacy Essentials

Data Literacy is for all, even absolute beginners. Jump on board with this free e-learning  and boost your career prospects.

Get Started

Article Labels
Article Tags