This #D3Thursday we will look at how we can animate a D3.js area graph over time in SAS Visual Analytics (VA).
Animated Area Chart
As with previous posts I've included sample data (Sample_Data_12.sas7bdat) for this post to help you get started. This data set is significantly larger than any we have use before, so you will have to override VA's system data limit in order to send all 50,000+ rows to our graph. System Data Limit
Handling the Data
The first step in creating our animation is getting our data into the right shape.
// Extract data sets from 2d array format into associative array format DATA_SETS = []; let dateDatum, binDatum, binTotal; let i = 0; let length = VA_MESSAGE.data.length; while (i < length) { dateDatum = { date: VA_MESSAGE.data[i][0], entries: [] }; while (i < length && _.isEqual(dateDatum.date, VA_MESSAGE.data[i][0])) { binTotal = 0; binDatum = { bin: VA_MESSAGE.data[i][1] }; while (i < length && binDatum.bin == VA_MESSAGE.data[i][1]) { binDatum[VA_MESSAGE.data[i][2]] = VA_MESSAGE.data[i][3]; binTotal += VA_MESSAGE.data[i][3]; i++; } Y_MAXIMUM = Math.max(Y_MAXIMUM, binTotal); dateDatum.entries.push(binDatum); } DATA_SETS.push(dateDatum); } // Sort by earliest date and lowest bin DATA_SETS.sort(function(a, b) { return Date.parse(a.date) - Date.parse(b.date); }); for (let i = 0; i < DATA_SETS.length; i++) { DATA_SETS[i].entries.sort(function(a, b) { return a.bin - b.bin; }); } ... // Create data stack STACKED_DATA = STACK(DATA_SETS[DATA_INDEX].entries);
There are two key things that are different here from previous weeks. First, we no longer have a single data set. Instead, we have an array of data sets (one for each date/time). Secondly, we must sort our data sets by date and bin in order to render our areas correctly.
Adding the Input Controls
Now that we have our data in the right shape, we can create the input controls we will need to control our animations.
<div class="input-container"> <div id="row1" class="row"> <span id="play-button"> <i class="fas fa-play-circle"></i> </span> <p id="min-date"> </p> <input id="date-slider" class="slider" type="range" min="0" max="100" value="0" step="1"> <p id="max-date"> </p> </div> </div>
You have a lot of options here for what inputs you want to create, and even more options for how to style them. I decided to use FontAwesome icons to create the play button since the library is lightweight and easy to work with. You could also use a custom icon stored on your server for this, or create it with SVG graphics.
I also decided to use a standard HTML slider input and two paragraph tags for the date control. Again, I did this for simplicity, but you could instead use a jQuery or D3.js plug-in slider.
Also notice that I hard coded these HTML elements. For maximum portability, you would want your D3.js code to create these elements on your page, but this is simpler and works for our example.
Binding Control Functions
Now that we have our input controls on the page, we can bind the functionality we want to each element.
// Animate when play button clicked d3.select("#play-button").on("click", function() { // Toggle playing boolean PLAYING = !PLAYING; // Update play button d3.select("#play-button i") .classed("fa-pause-circle", PLAYING ? true : false) .classed("fa-play-circle", PLAYING ? false : true); // Animate when played if (PLAYING) { // Reset slider if we are at end of data sets if (DATA_INDEX == DATA_SETS.length - 1) { d3.select("#date-slider").node().value = 0; } animateElements(); } }); // Redraw when date slider moved d3.select("#date-slider").on("input", function() { // Update data index and data set, stop playing DATA_INDEX = parseInt(this.value); STACKED_DATA = STACK(DATA_SETS[DATA_INDEX].entries); PLAYING = false; // Update progress bar on slider d3.select(this) .attr("value", this.value) .style("background-image", function() { const middle = (parseInt(this.value) - parseInt(this.min)) / (parseInt(this.max) - parseInt(this.min)); return ( "-webkit-gradient(linear, left top, right top, " + "color-stop(" + middle + ", #777777), " + "color-stop(" + middle + ", #d3d3d3)" + ")" ); }); // Change play button to pause d3.select("#play-button i") .classed("fa-pause-circle", false) .classed("fa-play-circle", true); // Redraw selected data set drawElements(); });
Animating
Finally, we can get around to the business of writing the animateElements function. We could have simply used our existing updateElements function to handle the animation, but I prefer to keep it separate for simplicity. The updateElements function has to handle changes to many elements (the scales, axes, legend, etc.) that won't change while animating over time. Instead of reusing our complicated update function, I've elected to create a more compact animateElements function.
// Redraw elements changed over time function animateElements() { // Iterate to next dataset DATA_INDEX = DATA_INDEX >= DATA_SETS.length - 1 ? 0 : DATA_INDEX + 1; STACKED_DATA = STACK(DATA_SETS[DATA_INDEX].entries); // Update date slider d3.select("#date-slider") .style("background-image", function() { const middle = (parseInt(this.value) + parseInt(this.step) - parseInt(this.min)) / (parseInt(this.max) - parseInt(this.min)); return ( "-webkit-gradient(linear, left top, right top, " + "color-stop(" + middle + ", #777777), " + "color-stop(" + middle + ", #d3d3d3)" + ")" ); }) .node() .stepUp(); // Update date texts DATE_TEXT = SVG.selectAll(".date-text").data([DATA_SETS[DATA_INDEX].date]); DATE_TEXT.text(function(d) { return DATE_FORMAT(d); }); // Update data paths DATA_PATHS = G_CHART_AREA.selectAll(".data-path").data( STACKED_DATA, function(d) { return d.key; } ); DATA_PATHS.transition() .duration(ANIMATION_TRANS_TIME) .attr("d", AREA); // If playing and not at end of data sets, set timer to animate again if (PLAYING && DATA_INDEX < DATA_SETS.length - 1) { setTimeout(animateElements, ANIMATION_TRANS_TIME); } // Otherwise, pause else if (DATA_INDEX == DATA_SETS.length - 1) { PLAYING = false; d3.select("#play-button i") .classed("fa-pause-circle", false) .classed("fa-play-circle", true); } }
The one thing worth noting here is that we aren't actually doing any interpolating on the date text element. Since our data set spans such a large range of dates, interpolating across all date values becomes dizzying. If, however, you were animating over a smaller number of dates that were close together you may want to interpolate the text to smooth out the animation.
Controlling Animation Speed
As a bonus, we will add another input slider to allow the user to control the speed of the animation.
<div class="input-container"> ... <div id="row2" class="row"> <p id="slow-text">Slow</p> <input id="speed-slider" class="slider" type="range" min="20" max="500" value="100"> <p id="fast-text">Fast</p> </div> </div> ... // Update animation time when speed slider moved d3.select("#speed-slider").on("input", function() { ANIMATION_TRANS_TIME = parseInt(this.value); });
Giving the user the ability to control the animation speed is a great idea since we don't know how many dates we will be animating over or what length of time is between them. Here the user can select any animation duration between 20 and 500 milliseconds, which should be sufficient for most situations.
Additional Resources
Animated Area GitHub
Animated Area GitHub Pages
The Future of #D3Thursday
Unfortunately, this post marks the end of the 12 planned #D3Thursday posts. Don't fret though, as I'm not going anywhere and will be continuing to work on awesome third-party visualizations to use in VA. Stay posted for additional #D3Thursday content and be sure to reach out with any ideas you have 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!
... View more