This #D3Thursday we will take on the challenge of creating a radar chart, a visualization not currently available in SAS Visual Analytics (VA)!
This post’s topics include:
Modifying a standard data set for this chart type
One of the features of our radar chart is the ability to dynamically toggle which numerical values are being visualized by the chart. In order to do this, we need to modify our data set to a specific structure. Imagine we have the following data set.
Vehicle Type | MSRP | MPG | Horsepower |
Hybrid | 25000 | 55 | 150 |
Sports | 40000 | 22 | 320 |
If we decide to visualize this data on a radar chart we would have to assign the categorical role "Vehicle Type" and the numerical roles "MSRP", "MPG", and "Horsepower". After doing so, however, we have no way to allow the user to then toggle on or off these numerical roles. In order to do this we must transform our data to look like the following.
Vehicle Type | Metric | Measure | Format |
Hybrid | MSRP | 25000 | DOLLAR8.2 |
Sports | MSRP | 40000 | DOLLAR8.2 |
Hybrid | MPG | 55 | BEST12.2 |
Sports | MPG | 22 | BEST12.2 |
Hybrid | Horsepower | 150 | BEST12.2 |
Sports | Horsepower | 320 | BEST12.2 |
Now we only have to assign the roles "Vehicle Type", "Metric", "Measure", and "Format" to our radar chart no matter how many numerical variables we intend to visualize. Additionally, we can now use the "Metric" column to filter which numerical variables get sent to the radar chart! Notice that we also added a column for format. Since we are now storing the values for different metrics in the same column, we must store the format for each row.
To keep you from getting bogged down in the data transformation I've included a the following files in the GitHub repository!
File Name | Usage |
7_Radar_Data_Cars.sas7bdat | Import this already modified file into VA. |
7_Radar_Data_Prep.sas |
Using SAS Studio or SAS 9, modify the hard coded variables in this SAS file as instructed to prepare any data set for use with the D3 radar chart. Import the output data set to VA using CAS actions or by outputting a sas7bdat file to import. |
7_Radar_Data_Prep.ctm |
Using SAS Studio, open the custom task file. Assign variables as instructed by the task. Import the output data set to VA using CAS actions or by outputting a sas7bdat file to import. |
The elements used to create a radar chart are nothing new (circles, lines, and paths) and arranging them to create a radar chart is fairly straightforward. The complexity of this example really comes from the transitions. Our radar chart has to support both entering and exiting categories as well as entering and exiting metrics.
The following code shows how I've handled entering and exiting metrics using nested transitions.
DATA_PATHS
...
.each(function(d, i) { // Find where point was entered/removed and add duplicate point for entered point
...
})
.transition()
.duration(TRANS_TIME)
.attr("d", function(d, i) { // Transition data path to new set of points
const dPath = d;
const iPath = i;
const points = [];
let path = "M ";
let x, y;
for (let j = 0; j < dPath.metrics.length; j++) {
if (
(!d.entered && d.changedIndex == j) ||
(j == dPath.metrics.length - 1 &&
d.changedIndex == dPath.metrics.length)
) { // If point was removed create a duplicate for transition
x =
RADIUS *
dPath.metrics[j % dPath.metrics.length].scaled_measure *
Math.sin((j % dPath.metrics.length) * DELTA_ANGLE);
y =
-RADIUS *
dPath.metrics[j % dPath.metrics.length].scaled_measure *
Math.cos((j % dPath.metrics.length) * DELTA_ANGLE);
path += x + "," + y + " L ";
}
x = RADIUS * dPath.metrics[j].scaled_measure * Math.sin(j * DELTA_ANGLE);
y = -RADIUS * dPath.metrics[j].scaled_measure * Math.cos(j * DELTA_ANGLE);
points.push({
category: dPath.category,
metric: dPath.metrics[j].metric,
measure: dPath.metrics[j].measure,
scaled_measure: dPath.metrics[j].scaled_measure,
x: x,
y: y
});
path += x + "," + y + (j == dPath.metrics.length - 1 ? " Z" : " L ");
}
// Update data points for current category using points array generated above
DATA_POINTS = G_CHART_AREA.selectAll(".data-point")
.filter(function(d) {
return dPath.category == d.category;
})
.data(points, function(d) {
return d.metric;
});
DATA_POINTS.attr(
"fill",
FILL[dPath.category] ? FILL[dPath.category] : COLOR_SCALE(iPath)
)
.transition()
.duration(TRANS_TIME)
.attr("cx", function(d) {
return d.x;
})
.attr("cy", function(d) {
return d.y;
});
...
return path;
})
.on("end", function(d) { // Update data attributes and remove duplicate point if point was removed
...
});
For the sake of brevity I've omitted a good portion of the code for the above transitions. Still, we are doing some interesting things here worth noting.
First, adding a point to our path isn't is as simple as transitioning from a path with x points to a path with x+1 points. This is because the path interpolator in D3 doesn't know how to smoothly transition between paths with different numbers of points. To compensate for this problem we must add duplicate points to make the paths we are transitioning between have the same number of points.
Secondly, we are nesting the transitions for the DATA_POINTS within the transitions for the DATA_PATHS. There are a couple of reasons to do this.
As you can likely tell from the code above, the transitions we are dealing with in this example are significantly more complex than those from previous weeks. As such, we don't want to complicate things by dealing with subsequent transitions the same way we did in previous posts. Instead, we are going to use transition queuing.
// Attach event for data message from VA
va.messagingUtil.setOnDataReceivedCallback(handleCallback);
...
// Use timeouts to debounce update events
function handleCallback(messageFromVA) {
if (LAST_TRANSITION_END > Date.now()) {
setTimeout(function() {
onDataReceived(messageFromVA);
}, LAST_TRANSITION_END - Date.now());
LAST_TRANSITION_END = LAST_TRANSITION_END + TRANS_TIME + TRANS_DELAY;
} else {
onDataReceived(messageFromVA);
LAST_TRANSITION_END = Date.now() + TRANS_TIME + TRANS_DELAY;
}
}
This code prevents a new transition from occurring until the previous transition has finished. The result is that in the case of rapid changes being made to the data set, our chart will process each change separately.
Like all design decisions, however, there are some compromises to handling transitions via queuing. If a filter event occurs causing a query before a previous query has finished, VA will cancel the first query and issue a second query instead. This means our chart will only receive one data set that is the result of both filter events and could have multiple entered or exited nodes. Currently, our transitions are written for only a single entered or exited node so our transitions will be jarring as D3 interpolates between paths with a different number of points.
Some of our previous examples had the issue of overflowing over the edge of the iframe causing part of our visualization to be cutoff. Let's look at a few steps we can take to mitigate this issue.
One simple step we can take to mitigate overflow issues is modifying our CSS to have a minimum width and height.
html, body, svg {
float: left;
min-width: 300px;
min-height: 200px;
margin: 0px;
width: 100%;
height: 100%;
}
Unlike other VA objects, the DDC can't render anything outside of it's object container. This means that tooltips, legend entries, or any other element that is too large will be cutoff. By applying a minimum width and height and allowing overflow we can prevent overflow cutoff. The downside is that our iframe will create scroll bars when it's container shrinks beyond the minimum dimensions, but this is a small price to pay to ensure that our data is always visible.
Note that you could dynamically assign the minimum dimensions based on the data and chart being rendered. You could set minimum dimensions and font sizes for all elements and then calculate the minimum dimensions needed to prevent cutoff. This solution would be more elegant, but isn't generalizable across multiple visualizations. We will choose to avoid the hassle in favor of the easier solution for now.
The changes above will help prevent cutoff, but we still have an issue. The way our tooltips are currently being placed results in the tooltips spilling out of frame. If the tooltips overflow on the left or top of the page then they will still be cutoff. Otherwise, a scroll bar will appear and the user will have to scroll to see the tooltip.
To solve this, we will create our own tooltips that are statically placed at the center of the visualization.
function showTip(d) {
// Prevent event from falling through to underlying elements
d3.event.stopPropagation();
// Update tooltips based on clicked data point
TIPS = d3
.select("body")
.selectAll(".tip")
.data([d], function(d) {
return d.category + "-" + d.metric;
});
...
TIPS.enter()
.append("div")
.classed("tip", true)
.html(function() {
return (
"<i class='tip-close fas fa-times'></i>" +
"<table class='tip-content'> <tr> <td> " +
METADATA.category +
":\t</td> <td>" +
d.category +
"</td> </tr>" +
"<tr> <td> " +
d.metric +
":\t</td> <td>" +
METADATA.metrics[d.metric].format(d.measure) +
"</td> </tr>" +
"<tr> <td> " +
d.metric +
" Scaled:\t</td> <td>" +
SCALED_FORMAT(d.scaled_measure * 100) +
"</td> </tr> </table>"
);
})
...
.on("end", function() {
d3.select(this)
.select(".tip-close")
.on("click", hideTip);
});
...
}
Again, this is only a small portion of the code to create the tooltips. The showTips function shown above creates a div with an html table and Font Awesome close icon and transitions it into place. The result looks like the following.
Next post we will take a look at how we can use VA selections in order to create a variable view stacked bar chart.
Remember to follow these articles on the SAS Communities Library and the #D3Thursday Twitter hashtag. Comment below or Tweet your questions and comments!
is it html code?
Yes, the above visualization is created via JavaScript embedded in an HTML document. You can test out this visualization in Visual Analytics by copying the URL for the demo file (https://sassoftware.github.io/sas-visualanalytics-thirdpartyvisualizations/samples/D3Thursday/7_Rada...) and pasting it into the URL option field for your data-driven content object. For production, you should create a copy of the HTML file (https://github.com/sassoftware/sas-visualanalytics-thirdpartyvisualizations/blob/master/samples/D3Th...) and place it on a server you own so that it can be accessed from your VA server. You would then link to the production version of the file on your server, rather than the demo file linked above. For an in depth tutorial on creating and linking third-party content in VA, please see my first D3 Thursday post: https://communities.sas.com/t5/SAS-Communities-Library/Customize-data-visualizations-in-SAS-Visual-A....
I've combined the radar chart with a preceding treeselector data-driven object and it works like a charm.
But when I select variables that have positive and negative values, the spider layout gets compromised because it scales taking the maximum as a reference, instead of the data range like in this case.
I'm struggling to adapt de d3 java script code.
In the original version the relevant code section I'd say it the following:
// Create nest to help process tall data structure
const nestedData = d3
.nest()
.key(function(d) {
return d[1];
})
.entries(VA_MESSAGE.data);
const numMeasures = nestedData.length;
// Restructure metadata from data message
OLD_METADATA = METADATA;
DELTA_ANGLE = (2 * Math.PI) / numMeasures;
METADATA = {
category: VA_MESSAGE.columns[0].label,
metrics: {}
};
for (let i = 0; i < numMeasures; i++) {
METADATA.metrics[nestedData[i].key] = {
metric: nestedData[i].key,
angle: i * DELTA_ANGLE,
format: translateFormat(VA_MESSAGE.data[i][3]),
maximum: d3.max(VA_MESSAGE.data, function(d) {
if (d[1] == nestedData[i].key) {
return d[2];
} else {
return Number.MIN_VALUE;
}
})
};
}
// Restructure data from 2d array to array of objects
let datum, metric;
OLD_DATA = DATA;
DATA = [];
for (let i = 0; i < VA_MESSAGE.data.length / numMeasures; i++) {
// Iterate over each category
datum = {
category: VA_MESSAGE.data[i * numMeasures][0],
id: "id-" + VA_MESSAGE.data[i * numMeasures][0].replace(/[\W]/g, "_"),
metrics: []
};
for (let j = 0; j < numMeasures; j++) {
// Iterate over metrics
metric = {
category: datum.category,
metric: VA_MESSAGE.data[i * numMeasures + j][1],
measure: VA_MESSAGE.data[i * numMeasures + j][2],
scaledMeasure:
VA_MESSAGE.data[i * numMeasures + j][2] /
METADATA.metrics[VA_MESSAGE.data[i * numMeasures + j][1]].maximum
};
datum.metrics.push(metric);
}
DATA.push(datum);
}
I haven't found a function that takes the range so I try to substract the min value from the max value.
But the code derails if I put it like this:
for (let i = 0; i < numMeasures; i++) {
METADATA.metrics[nestedData[i].key] = {
metric: nestedData[i].key,
angle: i * DELTA_ANGLE,
format: translateFormat(VA_MESSAGE.data[i][3]),
maximum:
d3.max(VA_MESSAGE.data, function(d) {
if (d[1] == nestedData[i].key) {
return d[2];
} else {
return Number.MIN_VALUE;
}
})
-
d3.min(VA_MESSAGE.data, function(d) {
if (d[1] == nestedData[i].key) {
return d[2];
} else {
return Number.MIN_VALUE;
}
})
};
}
I'm not confortable at all with javascript.
Can someone help me out?
Registration is now open for SAS Innovate 2025 , our biggest and most exciting global event of the year! Join us in Orlando, FL, May 6-9.
Sign up by Dec. 31 to get the 2024 rate of just $495.
Register now!
Data Literacy is for all, even absolute beginners. Jump on board with this free e-learning and boost your career prospects.