BookmarkSubscribeRSS Feed

Dynamic and interactive bar charts in SAS Visual Analytics with #D3Thursday

Started ‎09-20-2018 by
Modified ‎03-25-2019 by
Views 4,651

In this #D3Thursday article, we're going to expand on the basic bar chart example from last time to create a dynamic and interactive bar chart.

 

Dynamic Bar ChartDynamic Bar Chart

 

 This post’s topics include:

  • Reviewing how SAS Visual Analytics (VA) communicates with the Data-Driven Content (DDC) Object
  • Creating a function structure to handle dynamic updates
  • Transitioning entering/exiting/updating elements
  • Handling selections and interactions within SAS Visual Analytics

DDC Messages

There are three kinds of messages exchanged by VA with the DDC object:

  1. Data Messages (VA to DDC) – These messages contain the data and metadata for the roles assigned by VA. This message is what the jsonDataViewer utility we looked at in post one shows. Note that this message will contain an extra data column to indicate whether a row of data is selected if there is a linked-selection acting on the DDC.
  2. Instructional Messages (DDC to VA) – These messages contain messages to be displayed over the DDC object in VA. Typically these messages are used when the data roles assigned by VA are not what we expect in our code.
  3. Selection Messages (DDC to VA) – These messages contain selections made in the DDC. These messages allow the DDC to interact with other objects in the report via filters or linked-selections.

Below we will look at the code required to handle each of these message types. For more information on these messages see the links at the bottom of this post.

 

A Dynamic Skeleton

Setting Up Event Handlers

To create a more dynamic visualization, we need to create a function structure to handle dynamic updates. There are two major events we need to worry about: data received events and resize events. Luckily, we have already written some utilities to make handling these events easier.

 

Let’s start by importing these utilities: 

<script type="text/javascript" src="../../util/messagingUtil.js"></script>
<script type="text/javascript" src="../../util/contentUtil.js"></script>

Now we can bind some callback functions to our event listeners:   

// Attach event for data message from VA
va.messagingUtil.setOnDataReceivedCallback(onDataReceived);

// If not being rendered in iFrame (outside VA), render with sample data 
if (!inIframe()) { 
  onDataReceived(SAMPLE_MESSAGE); 
}

// Listen for resize event
va.contentUtil.setupResizeListener(drawElements);

// Take action on received data
function onDataReceived(messageFromVA) {
}

// Draw elements for first time and on resize event
function drawElements() {
}

// Redraw data dependent elements on data change
function updateElements() {
}

In the above code we are also calling the onDataReceived with a sample data message callback if our page isn’t being rendered in an iFrame. This is so we can test our visualizations independently of VA. We hardcoded sample data obtained using the jsonDataViewer under the variable SAMPLE_MESSAGE for this purpose. Note that the inIframe() function is defined later on in this code. 

 

Data Received Events

Now let’s look at how we handle data received events: 

function onDataReceived(messageFromVA) {
  // Initialize data variables
  VA_MESSAGE = messageFromVA;
  VA_RESULT_NAME = messageFromVA.resultName;

  // Validate data roles
  if (
    !va.contentUtil.validateRoles(messageFromVA, ["string", "number"], null)
  ) {
    va.messagingUtil.postInstructionalMessage(
      VA_RESULT_NAME,
      "D3 Bar Chart expects columns to be assigned in this order:\n" +
        " 1. Category (string)\n" +
        " 2. Measure (number)"
    );
    return;
  }

  // Restructure data from 2d array to array of objects
  DATA = [];
  for (let i = 0; i < VA_MESSAGE.data.length; i++) {
    DATA.push({
      x: VA_MESSAGE.data[i][0],
      y: VA_MESSAGE.data[i][1]
    });
  }

  // Initialize chart if first draw, otherwise process data and update elements accordingly
  if (d3.select("#" + SVG_ID).empty()) {
    drawElements();
  } else {
    updateElements();
  }
} 

There are a few interesting things being done here.

 

First, using the utilities imported above we can ensure that data roles are properly assigned for our visualization (e.g. our bar chart requires a string variable followed by a numeric variable). If roles are not assigned properly, we can use said utilities to send an instructional message back to VA to instruct the user about how to assign roles.

 

Secondly, we are restructuring our data from the two-dimensional array format used by VA to an array of objects. This isn’t strictly necessary, but it poses some benefit. For starters, if we ever decide to change the order of the roles we would only have to change it where the roles are validated and where the data is parsed, rather than having to change every reference to the dataset. Also, we sometimes need to do complicated processing to our data and even add variables. In this example we are only storing the raw value passed in from VA (x, y, and selected), but this won’t be the case in future examples.

 

Finally, we either call drawElements if this is the first time the chart is rendered, or otherwise call updateElements. We could combine these functions and reduce some redundant code, but doing so would complicate and slow our code. Since we want to treat the initial draw differently than we treat updates (as you will see later when we implement transitions) it is better to keep these functions separate and deal with a little redundant code.

 

Resize Events

Now let’s look at how we can handle resize events: 

function drawElements() {
  // Return if data is not yet initialized
  if (!DATA) {
    return;
  }

  // Calculate dimensions for graph based on container dimensions
  WIDTH = document.body.clientWidth;
  HEIGHT = document.body.clientHeight;
  CHART_WIDTH = WIDTH - Y_AXIS_WIDTH - EDGE_PADDING;
  CHART_HEIGHT = HEIGHT - X_AXIS_HEIGHT - EDGE_PADDING;

  // Append/update all groups and save references

  ...

  // Create x scale
  X_SCALE = d3
    .scaleBand()
    .rangeRound([0, CHART_WIDTH])
    .padding(0.1)
    .domain(
      DATA.map(function(d) {
        return d.x;
      })
    );

  ...

  // Create x axis
  X_AXIS = SVG.selectAll(".x-axis").data([DATA]);

  X_AXIS.enter()
    .append("g")
    .classed("x-axis", true)
    .merge(X_AXIS)
    .attr(
      "transform",
      "translate(" + Y_AXIS_WIDTH + ", " + (HEIGHT - X_AXIS_HEIGHT) + ")"
    )
    .call(d3.axisBottom(X_SCALE));

  ...

  // Create bars for each
  DATA_BARS = G_CHART_AREA.selectAll(".data-bar").data(DATA, function(d) {
    return d.x;
  });

  DATA_BARS.enter()
    .append("rect")
    .classed("data-bar", true)
    .merge(DATA_BARS)
    .attr("x", function(d) {
      return X_SCALE(d.x);
    })
    .attr("y", function(d) {
      return Y_SCALE(d.y);
    })
    .attr("width", function() {
      return X_SCALE.bandwidth();
    })
    .attr("height", function(d) {
      return CHART_HEIGHT - Y_SCALE(d.y);
    });
}

This should look very similar to the code from the basic bar chart we created last time, with a few exceptions.

 

First, at the very start of the drawElements function we check to see if DATA has been initialized. This is to prevent a redraw in the case that a resize event occurs before a data message has been received from VA.

 

Secondly, we must update the dimensions of the graph at the top of this function. Since we will be redrawing the graph on every resize event, we must ensure that any dimensions we use when drawing the graph are set to the correct values.

 

Finally, we are using the merge function when we are entering elements. This function merges the enter and update queue for the data join you are calling it on. This means that all functions in the chain after the merge apply to both the enter queue and the update queue, reducing redundant code.

 

Without using merge, the code for entering and updating data bars would look like: 

// Create bars for each
DATA_BARS = G_CHART_AREA.selectAll(".data-bar").data(DATA, function(d) {
  return d.x;
});

DATA_BARS
  .attr("x", function(d) {
    return X_SCALE(d.x);
  })
  .attr("y", function(d) {
    return Y_SCALE(d.y);
  })
  .attr("width", function() {
    return X_SCALE.bandwidth();
  })
  .attr("height", function(d) {
    return CHART_HEIGHT - Y_SCALE(d.y);
  });

DATA_BARS.enter()
  .append("rect")
  .classed("data-bar", true)
  .attr("x", function(d) {
    return X_SCALE(d.x);
  })
  .attr("y", function(d) {
    return Y_SCALE(d.y);
  })
  .attr("width", function() {
    return X_SCALE.bandwidth();
  })
  .attr("height", function(d) {
    return CHART_HEIGHT - Y_SCALE(d.y);
  });

 

Transitioning Elements

Let’s move on to look at how we can handle updates to our data set: 

function updateElements() {
  // Update x scale
  X_SCALE = d3
    .scaleBand()
    .rangeRound([0, CHART_WIDTH])
    .padding(0.1)
    .domain(
      DATA.map(function(d) {
        return d.x;
      })
    );

  // Update y scale
  Y_SCALE = d3
    .scaleLinear()
    .rangeRound([CHART_HEIGHT, 0])
    .domain([
      0,
      d3.max(DATA, function(d) {
        return d.y;
      })
    ]);

  // Update x axis
  X_AXIS = SVG.selectAll(".x-axis").data([DATA]);

  X_AXIS.transition()
    .duration(TRANS_TIME)
    .call(d3.axisBottom(X_SCALE));

  // Update x axis
  Y_AXIS = SVG.selectAll(".y-axis").data([DATA]);

  Y_AXIS.transition()
    .duration(TRANS_TIME)
    .call(d3.axisLeft(Y_SCALE));

  // Enter/Update/Exit data bars
  DATA_BARS = G_CHART_AREA.selectAll(".data-bar").data(DATA, function(d) {
    return d.x;
  });

  DATA_BARS.transition()
    .duration(TRANS_TIME)
    .attr("x", function(d) {
      return X_SCALE(d.x);
    })
    .attr("y", function(d) {
      return Y_SCALE(d.y);
    })
    .attr("width", function() {
      return X_SCALE.bandwidth();
    })
    .attr("height", function(d) {
      return CHART_HEIGHT - Y_SCALE(d.y);
    })
    .style("opacity", 1);

  DATA_BARS.enter()
    .append("rect")
    .classed("data-bar", true)
    .attr("x", function(d) {
      return X_SCALE(d.x);
    })
    .attr("y", function(d) {
      return Y_SCALE(d.y);
    })
    .attr("width", function() {
      return X_SCALE.bandwidth();
    })
    .attr("height", function(d) {
      return CHART_HEIGHT - Y_SCALE(d.y);
    })
    .style("opacity", 0)
    .transition()
    .duration(TRANS_TIME)
    .style("opacity", 1);

  DATA_BARS.exit()
    .transition()
    .duration(TRANS_TIME)
    .attr("data-test", function() {
      return 0;
    })
    .style("opacity", 0)
    .remove();
}  

Rather than just redraw the entire graph (like we do on a resize event) we would like to animate the entering, updating, and exiting of elements as our data set is updated. Using animations to create seamless transition will make it easier for the user to understand the changes that are happening to the data set as they are made.

 

We can create such animations as shown above with D3’s transition function. This function creates an interpolator which transitions the specified attributes or styles from their initial value to the specified final value (where possible). For instance, in the code above the data bars are entered on an update with an initial opacity of zero and transitioned over a set duration to an opacity of one.

 

For this example, five transitions are required to seamlessly update the elements:

  1. The x-axis must be transitioned to its new scale.
  2. The y-axis must be transitioned to its new scale.
  3. The updated data bars must be slid into their new position.
  4. The entered data bars must be faded in.
  5. The exited data bars must be faded out.

As shown in this code, the D3.js transition function is capable of not only interpolating numerical values (like x, y, width, height, and opacity values) but also entire axes. There are limits to the default D3.js interpolating capabilities, however, as we will see in next time’s example.

 

For more information on transitions see the links at the bottom of this post.

 

Handle Selections

Setting Up Event Handlers

As with handling data and resize events, we must start handling selections by binding the appropriate callback functions to our event handlers: 

// SVG selection in drawElements
d3.select("body")
  .selectAll("#" + SVG_ID)
  .data([DATA])
  .enter()
  .append("svg")
  .attr("id", SVG_ID)
  .on("click", deselectAllElements)
  ...

// Data bar enter in drawElements and updateElements
DATA_BARS.enter()
  .append("rect")
  .classed("data-bar", true)
  .classed("selectable", true)
  .on("click", function(d, i) {
    selectElement(i, this);
  })
...

We want to imitate the same selection functionality that VA implements. With these bindings, clicking any selectable element will select it and clicking anywhere else in the visualization will deselect all elements.

 

Also notice that we are adding the class selectable to our data elements here to generalize our selection event processing below.

 

Selection Events

Now that we have our event handlers setup, let’s take a look at our callback functions: 

// Deselect all on svg click
function deselectAllElements() {
  // Deselect all elements
  d3.selectAll(".selectable").classed("selected", false);

  // Post message to VA
  va.messagingUtil.postSelectionMessage(VA_RESULT_NAME, []);
}

// Handle selection on element
function selectElement(index, el) {
  // Prevent event from falling through to underlying elements
  d3.event.stopPropagation();

  // If control is held toggle selected on click preserving array, otherwise select only clicked element
  if (d3.event.ctrlKey) {
    // Toggle selection on clicked element
    d3.select(el).classed("selected", !d3.select(el).classed("selected"));

    // Build array of selected elements
    const selections = [];
    d3.selectAll(".selectable").each(function(d, i) {
      if (d3.select(this).classed("selected")) {
        selections.push({ row: i });
      }
    });

    // Post message to VA
    va.messagingUtil.postSelectionMessage(VA_RESULT_NAME, selections);
  } else {
    // Deselect all elements
    d3.selectAll(".selectable").classed("selected", false);

    // Select clicked element
    d3.select(el).classed("selected", true);

    // Post message to VA
    va.messagingUtil.postSelectionMessage(VA_RESULT_NAME, [{ row: index }]);
  }
} 

The important thing to note here is how we are indicating to VA that a row has been selected. We are sending a message back to VA with an array of objects where each object takes the form {row: selected_index}. Since we are using the position of the data-bar in the DOM to determine which row has been selected we must keep our data elements in the same order that they were sent in by VA.

 

Initial Selections

Now we need to modify our data received callback to process selections sent in from VA.

 

First we need to extract the selection information from the VA data message: 

// Initialize data variables
VA_MESSAGE = messageFromVA;
VA_RESULT_NAME = messageFromVA.resultName;
SELECTED = va.contentUtil.initializeSelections(messageFromVA);

VA sends selection information by attaching an additional column to the data in the data message. Known as the brush column, this additional column will contain a zero for non-selected rows and a one for selected rows.

 

Since this column is only attached if a row in the data being sent to our chart is selected, we want to remove this column and store it as it’s own variable. This way we don’t have to worry about this column when validating roles. Luckily, we have already have a utility built to do this for us, as shown above.

 

Then, after we’ve drawn our chart, we need to apply the selections indicated in the VA data message: 

// Apply selections
d3.selectAll(".selectable")
  .classed("selected", false)
  .filter(function(d, i) {
    return SELECTED.find(function(selection) {
      return selection.row == i;
    });
  })
  .classed("selected", true); 

D3.js’ filter function allows us to filter our selection using any conditional. In this case, we are selecting only the elements whose index is present in the SELECTED array created above using the provided utility.

 

Interacting Within VA

Finally, let’s setup some other objects in our report for our dynamic bar chart to interact with. Let’s add a list object with the data role Type and a pie chart with data roles Type and Frequency. Next, let’s setup the actions so that the list filters both our dynamic bar chart and the pie chart. 

List ActionsList Actions

 Then setup a linked selection between the dynamic bar chart and the pie chart.

 

DDC ActionsDDC Actions

 The result is a list and pie chart that interact with our dynamic bar chart through selections.

 Dynamic Bar ChartDynamic Bar Chart

 

 Additional Resources

Next Post

Next time we will say good riddance to these ugly bar charts in favor of a donut chart. We will learn about storing data as element attributes, creating custom interpolators, and creating a mobile friendly legend.

 D3Thursday Logo.png

 

Remember to follow the D3 Thursday article series on the SAS Communities Library and the #D3Thursday Twitter hashtag. Comment below or Tweet your questions and comments!

Version history
Last update:
‎03-25-2019 09:58 AM
Updated by:
Contributors

sas-innovate-2024.png

Don't miss out on SAS Innovate - Register now for the FREE Livestream!

Can't make it to Vegas? No problem! Watch our general sessions LIVE or on-demand starting April 17th. Hear from SAS execs, best-selling author Adam Grant, Hot Ones host Sean Evans, top tech journalist Kara Swisher, AI expert Cassie Kozyrkov, and the mind-blowing dance crew iLuminate! Plus, get access to over 20 breakout sessions.

 

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