Designing a Reusable Line Chart in D3JS

Of late I have been refactoring the code within Dex from the ground up starting with the D3 templates.   I’m getting a little more confident in my Javascript so I decided to tackle the basic D3JS charting component usage patterns.

One of the generated assets from Dex will be a couple of pure Javascript library of reusable D3 assets which take the  burden out of charting.  It will be and extension on Mike Bostock’s concept of reusable charts discussed here.  However, I have added a few capabilities that will make them even simpler to implement and hopefully more powerful and interoperable.

One of the libraries will provide core reusable functions for data transformation and other repetitive task.   The other will provide reusable charting components.

In this blog post I will cover a sample LineChart component and the basics of how it will operate.

Basic Concepts

The simplest case of creating and rendering a LineChart is as follows:

var chart = new LineChart(
{
  parent : svg,
  labels : [ “A”, “B”],
  data   : [[0,0],[1,1],[2,4]]
});

At the very least, you must supply the parent to which the chart will be attached, a set of labels and the corresponding data.

You can then render it by simple calling it as a function:

chart();

And it can be reconfigured via the “attr” method which serves both as a getter and setter operation for the chart.  Once reconfigured it can be re-rendered.  This aligns well with D3’s existing concept of attributes and will look familiar to anyone who has used D3 before.  Here is an example:

chart.attr("height", 800).attr("width", 600)();

Additionally, charts can have other charts added as listeners via this same attr functionality.  In this example we configure chart1-3 to listen to events from each other:

chart1.attr("listeners", [chart2, chart3]);
chart2.attr("listeners", [chart1, chart3]);
chart3.attr("listeners", [chart1, chart2]);

Data Format and Binding

I have taken a minimalist viewpoint for data and binding.  I assume that all incoming data is available in two variables:

  1. labels – This will contain the name of each column of data, much like the header of a CSV file.
  2. data – This will contain the raw data to be charted.  It will consist of an array of rows.  Each row will consist of an array of data elements corresponding to the labels.

For example, lets assume we have a small table of data:

X Y
0 0
1 1
2 4
3 9

This could be configured as:

chart
  .attr("labels", [ "X", "Y"])
  .attr("data", [[0,0],[1,1],[2,4][3,9]]);

Of course data isn’t always resident in memory or in this format.  We will also provide utility routines for converting alternate forms of input into this form.  However, data conversion will not be the burden of the chart itself.  There is a clear separation of concerns between the charts and the data manipulation routines.

Working Examples

A Simple Example:

As it should be, the simple case is quite simple.

// Create an SVG for our chart.
var svg = d3.select("body").append("svg")
  .attr("width", 1000)
  .attr("height", 800)
  .append("g")
  .attr("transform", "translate(40,10)");

// Configure a chart.
var chart = LineChart(
{
  "parent": svg,
  "labels": [ "X", "Y" ],
  "data"  : [[-3,9],[-2,4],[-1,1],[0,0],[1,1],[2,4],[3,9]]
});

// Render the chart.
chart();

ReusableDemo1

A Cross Communication Example:

Here is a more involved example which provides 6 coordinated views into a single dataset.  As you mouse over any of the charts, they will communicate the event to their peers.

ReusableDemo2

The code for this example is more complicated than the first, but most of it is dedicated to setting up the data:

// Here is basic CSV data.  CSV will be the core
// input type for all of our reusable charts:
var data = [["x","abs(x)","x^2","x^3","cos(x)","sin(x)"]];

for (var i=-360;i<=360; i=i+10)
{
  data.push([i, Math.abs(i), i*i, i*i*i,
    Math.cos(i*(Math.PI/180)), Math.sin(i*(Math.PI/180)) ]);
}

// Extract the header:
var labels = data[0];

// Remove the header from the data.
data.splice(0, 1);

// Create an SVG for our chart.
var svg = d3.select("body").append("svg")
  .attr("width", 1000)
  .attr("height", 800)
  .append("g")
  .attr("transform", "translate(40,10)");

// Create an array of charts:
var charts = [];

// Create a chart for each label, assume the x axis
// is tied to the first label:
for (var yi=0; yi<labels.length; yi++)
{
  // Create the chart:
  var chart = LineChart(
  {
    "parent"  : svg,
    "xoffset" : ((yi%3) * 300) + 50,
    "yoffset" : parseInt(yi/3) * 250,
    "height"  : 200,
    "width"   : 200,
    "labels"  : labels,
    "data"    : data,
    "xi"      : 0,
    "yi"      : yi
  });

  // Add the chart to the list:
  charts.push(chart);
}

// Set up each chart to listen to its peers.
// Then render the chart:
for (var i=0; i<charts.length; i++)
{
  var listeners = [];
  for (var j=0; j<charts.length; j++)
  {
    // Make sure we do not talk to ourselves.
    if (i != j)
    {
      // Add this listener.
      listeners.push(charts[j]);
    }
    // Configure the listeners and render the chart.
    charts[i].attr("listeners", listeners)();
  }
}

The Line Chart Implementation

The LineChart implementation is straightforward.   It provides a reasonable default configuration so that users need only define those parameters which are in need of customization.

New parameters can be added and old ones deprecated without a change in the chart’s functional contract.  The configuration is loosely coupled with the chart via the use of the attribute name-value-pair.

As previously discussed, the charts can communicate back and forth with other components and as our needs evolve, new capabilities can be added to the chart and it’s configuration as such needs arise.

Configuration

This will change, but for now, here are the customization options in this strawman implementation:

NAME DESCRIPTION
parent The parent node of this chart.
labels An array containing the labels (ie: header names), of the columns of data.
data An array containing the raw data.
width The width of this chart in pixels.
height The height of this chart in pixels.
xi The index of the column to be used for the x axis.
yi The index of the column to be used for the y axis.
xoffset The x offset (relative to the parent node) in pixels indicating where this chart should be rendered.
yoffset The y offset (relative to the parent node) in pixels indicating where this chart should be rendered.

The Code

Here is the commented code:

////
//
// LineChart - This function will return a reusable LineChart with the
//             supplied configuration.
//
// parent     = A mandatory element indicating the parent node of this chart.
// labels     = The labels, names or headers for the data within this chart.
// data       = The data to be graphed in this chart.
// width      = The width in pixels of this chart.
// height     = The height in pixels of this chart.
// xi         = The index within the data array for the x axis.
// yi         = The index within the data array for the y axis.
// xoffset    = The x offset (relative to the parent) in pixels where we will
//              start rendering this chart.
// yoffset    = The y offset (relative to the parent) in pixels where we will
//              start rendering this chart.
//
////
function LineChart(config)
{
  // The event handler for mouse over.
  var mouseOverHandler;

  // Default parameters.
  var p =
  {
    parent          : null,
    labels          : [ "X", "Y" ],
    listeners       : [],
    data            : [[0,0],[1,1],[2,4],[3,9],[4,16]],
    width           : 600,
    height          : 400,
    xi              : 0,
    yi              : 1,
    xoffset         : 0,
    yoffset         : 0
  };

  // If we have user-defined parameters, override the defaults.
  if (config !== "undefined")
  {
    for (var prop in config)
    {
      p[prop] = config[prop];
    }
  }

  // Render this chart.
  function chart()
  {
    // Use a linear scale for x, map the value range to the pixel range.
    var x = d3.scale.linear()
      .domain(d3.extent(p.data, function(d) { return +d[p.xi]; }))
      .range([0, p.width]);

    // Use a linear scale for y, map the value range to the pixel range.
    var y = d3.scale.linear()
      .domain(d3.extent(p.data, function(d) { return +d[p.yi]; }))
      .range([p.height, 0]);

    // Create the x axis at the bottom.
    var xAxis = d3.svg.axis()
      .scale(x)
      .orient("bottom");

    // Create the y axis to the left.
    var yAxis = d3.svg.axis()
      .scale(y)
      .orient("left");

    // Define a function to draw the line.
    var line = d3.svg.line()
      .x(function(d) { return x(+d[p.xi]); })
      .y(function(d) { return y(+d[p.yi]); });

    // Append a graphics node to the parent, all drawing will be relative
    // to the supplied offsets.  This encapsulating transform simplifies
    // the offsets within the child nodes.
    var chartContainer = p.parent.append("g")
      .attr("transform", "translate(" + p.xoffset + "," + p.yoffset + ")");

    // Draw the x axis.
    chartContainer.append("g")
      .attr("class", "x axis")
      .attr("transform", "translate(0," + p.height + ")")
      .call(xAxis);

    // Draw the y axis.
    chartContainer.append("g")
      .attr("class", "y axis")
      .call(yAxis)
      .append("text")
      .attr("transform", "rotate(-90)")
      .attr("y", 6)
      .attr("dy", ".71em")
      .style("text-anchor", "end")
      .text(p.labels[p.yi]);

    // Draw the line.
    chartContainer.append("path")
      .datum(p.data)
      .attr("class", "line")
      .attr("d", line);

    // We handle mouseover with transparent rectangles.  This will calculate
    // the width of each rectangle.
    var rectalWidth = x(data[1][p.xi]) - x(data[0][p.xi]);

    // Add the transparent rectangles for our mouseover events.
    chartContainer.selectAll("rect")
    .data(data.map(function(d) { return d; }))
    .enter().append("rect")
    .attr("class", "overlay")
    .attr("transform", function(d,i) { return "translate(" + x(d[p.xi]) + ",0)"; })
    .attr("opacity", 0.0)
    .attr("width", rectalWidth)
    .attr("height", p.height)
    .on("mouseover", function(d)
    {
    	mouseOverHandler(d, true);
    });

    // This function handles the mouseover event.
    //
    // data will contain the row experiencing mouseover.
    // originator will be true if this is being called by the chart which
    //   is originating this event, false otherwise.  This is required to
    //   avoid recursion of listeners notifying originators.
    mouseOverHandler = function (data, originator)
    {
      // Remove any old circles.
      chartContainer.selectAll("circle").remove();

      // Draw a small red circle over the mouseover point.
      chartContainer.append("circle")
        .attr("fill", "red")
        .attr("r", 4)
        .attr("cx", x(data[p.xi]))
        .attr("cy", y(data[p.yi]));

      // If we are the originator of this event, notify our listeners to
      // update themselves in turn.
      if (originator)
      {
        for (var i=0; i&lt;p.listeners.length; i++)
        {
          p.listeners[i].onMouseover(data, false);
        }
      }
    }
  }

  // This is the public on mouseover function which is visible to others.
  chart.onMouseover = function(data, originator)
  {
    mouseOverHandler(data, originator);
  }

  // Use this routine to retrieve and update attributes.
  chart.attr = function(name, value)
  {
    // When no arguments are given, we return the current value of the
    // attribute back to the caller.
    if (arguments.length == 1)
    {
      return p[name];
    }
    // Given 2 arguments we set the name=value.
    else if (arguments.length == 2)
    {
      p[name] = value;
    }

    // Return the chart object back so we can chain the operations together.
    return chart;
  }

  // This routine supports the update operation for this chart.  This is
  // applicable when the chart should be partially updated.
  chart.update = function()
  {
  }

  // Return the instantiated chart object.
  return chart;
}

Conclusion

I look forward to receiving feedback before scaling this pattern further.

That’s it for now…

– Pat

Advertisements

About patmartin

I am a coder and Data Visualization/Machine Learning enthusiast.
This entry was posted in General. Bookmark the permalink.

One Response to Designing a Reusable Line Chart in D3JS

  1. Pingback: Over 2000 D3.js Examples and Demos | TechSlides

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s