BookmarkSubscribeRSS Feed

Create a radar chart in SAS Visual Analytics with #D3Thursday

Started ‎10-18-2018 by
Modified ‎04-09-2019 by
Views 9,182

This #D3Thursday we will take on the challenge of creating a radar chart, a visualization not currently available in SAS Visual Analytics (VA)!

Radar ChartRadar Chart This post’s topics include:

  • Modifying a standard data set for this chart type

  • Creating nested transitions
  • Handling transitions via transition queuing
  • Preventing chart overflow

The Data Set

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.

 

Nested Transitions

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.

  1. DATA_POINTS are logically tied to DATA_PATHS in that we always have the same number of vertices in the path element as we have points. Nesting these transitions allows us to compute the updated points arrays for both simultaneously.
  2. How we update DATA_POINTS depends on what type of transition is happening on our DATA_PATHS. For instance, the code above shows the update transition for our DATA_PATHS and is used when we enter or exit metrics. The way we update DATA_POINTS is different when we enter or exit categories, so nesting our transitions helps simplify the individual transitions.

 

Transition Queuing

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.

 

Preventing Overflow

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.

 

Minimum Dimensions

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.

 

Custom Tooltips

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.

Old TooltipsOld Tooltips

 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.

 

New TooltipsNew Tooltips

 

 

Additional Resources

Next Post

Next post we will take a look at how we can use VA selections in order to create a variable view stacked bar chart.D3Thursday Logo.png

 

Remember to follow these articles on the SAS Communities Library and the #D3Thursday Twitter hashtag. Comment below or Tweet your questions and comments!

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

@RyanWest @awee 

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?

Version history
Last update:
‎04-09-2019 11:57 AM
Updated by:
Contributors

SAS Innovate 2025: Register Now

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!

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