Sunday, February 10, 2013

Adding Interaction to Flot Graphs: Tooltips and Labeled Series

flot-interact-item-lines
Out-of-the-box, Flot does an awful lot for you with very little extra work. Even basic interactions are pretty simple to implement by binding to certain events produced by it. And if you really want to dig in and create more advanced interactivity, Flot exposes everything you need to accomplish that task. Probably one of the more common requirements in a graph is to be able to label points - either with the actual value at a given point or more information about the point that might not be evident on the graph. In my case, I wanted to both show the series label and point value when you hovered over a specific point in the graph and, when clicked, show labels for every point in the series. The former turned out to be relative simple, however, the latter took a little more effort to achieve.

For any graph that you want to respond to interactive events, you need to make sure you've enabled the correct options:


options.grid: { hoverable: true, clickable: true, autoHighlight: true };


The auto highlighting feature is the only interaction Flot handles for you. Anything else requires you to bind to the events Flot fires to add additional enhancements. Flot will fire a plothover event whenever the mouse moves over a datapoint but if only is hoverable = true. Additionally, it will fire a plotclick when clickable = true. Each event handler function is passed three arguments which can be used to create interactive features. So the basic setup to create a graph and bind to both events looks like this:


var plot;

plot = $.plot($('.graph'), data, options);

$('.graph').on('plothover', function ( event, pos, item ) {
...
}

$('.graph').on('plotclick', function ( event, pos, item ) {
...
}



I maintain a reference to the Flot object since I might need to call several API functions in the event handlers. Adding a label to the point you're hovering over is fairly straight forward. The information passed to the event handler contains both values about where the mouse is pointing and the normalized position of the data point. For my purposes, I always want the label to be positioned consistently relative to the data point, so I use the item object passed to the handler to find its page coordinates. The process of adding the label is as simple and creating the HTML containing information from the point, adding it to the DOM, and then positioning it over the graph near the point:



$('.graph').on('plothover', function ( event, pos, item ) {
var ofsh, ofsw;

if ( hoverTip )
hoverTip.remove();

if (item) {

hoverTip = $(toolTipHTML( item.series.data[item.dataIndex][1], item.series.label ));

$('.graph').parent().append(hoverTip);

ofsh = hoverTip.outerHeight();
ofsw = hoverTip.outerWidth();

hoverTip.offset({
left: item.pageX - ofsw / 2,
top: item.pageY - ofsh - 15
});
}
}


The item pageX/pageY values will always be the same for a point regardless of where the mouse is positioned. This allows you to create a label in a consistent spot relative to the point. For a line, its the point on the line, however, for a bar graph, the actual point will depend on the alignment of the bar to the axis label. The default will place it at the top/right of the bar. If you change the alignment to "center", then the point will be the top/center of the bar. The above code will result in the following label for a bar graph when you hover over the bar:

flot-interact-hover-datapoint

Since I'm using the stack plugin, finding the original data point value wasn't what I expected. If you look at the Flot documentation, the example shown uses the value in item.datapoint. However, the stack plugin modifies the datapoint values to create start/end point on the graph. If I used that information, the third series would have an y-axis value that was the sum of the two series below it:


// Hover over the third series point: X=3,Y=25
item.datapoint = [3, 34, 9]
item.series.data[item.dataIndex] = [3, 25]


The difference is 25 which is what I want in my label. However, I won't trust this because I may not always be using the stack plugin and the third index in the array won't always be there. Instead, I dug into the item object more and found the original data array (item.series.data) and can get my value from there.

Working with the actual point that is being hovered over or clicked is fairly straight forward. However, what if you want to add labels over the entire series when one of the series points is clicked?

flot-interact-click-series-labels

Since the item object is only providing the pageX/Y for the clicked point, you have to figure out how to find the pageX/Y of all the other points in the series. Fortunately, Flot exposes several helper functions to make this relatively easy. However, the documentation does not go very deep into how to really use them. It took some trial and error to completely understand how to obtain exactly what I wanted.

Internally, Flot has a representation of the data in what is referred to as point-space and canvas-space. The point-space is the actual data values of the series, however, normalized to match how the data will render on the canvas. The canvas-space is the actual pixel coordinates inside the canvas. The API function getAxes() provides functions for each axis called p2c() and c2p() which will translate between point-to-canvas space and canvas-to-point space, respectively. To find where a data point is on the canvas, you just call xaxis.p2c(x) and yaxis.p2c(y). The tricky part here is what is x and y. These are not necessarily the original data value. As I showed above, the stack plugin modified my data to create new values that represented the actual x/y point on the graph. In this case, 9 was the bottom of the bar on the y-axis and 34 was the top of the bar on the y-axis. To find the top of the bar in canvas-space, I need to pass 34 to yaxis.p2c().

Now, I need to find these pageX/Y values across all the points in a series. Going back to the API, I can access all the series data via getData(). Here, I can find the normalized points I need to pass to p2c() to find the positions of all the points in the series. So, to calculate the pageX/Y of each point in the series, I would follow these basic steps:


  1. Get a reference to the axis and series data via getAxis() and getData(), respectively

  2. Find the pageX/Y of the graph canvas since p2c() will return values relative to that point

  3. Iterate over each X-axis point and find it in the series datapoints

  4. Use the p2c() function from each axis to find the X/Y coordinates of the point in canvas-space

  5. Add the previously calculated canvas position to the values returned from p2c() to find the absolute page coordinates of the point



Here's what the code would look like to implement that logic:


$('.graph').on('plotclick', function ( event, pos, item ) {

var x = 0, ttip, fmtd, dp, pz, tmp, xtickl,
ofs = { pageX: 0, pageY: 0, height: 0, width: 0, plotX: 0, plotY: 0 },
axis = plot.getAxes(),
series = plot.getData(),
xcnt = series[0].data.length;

plot.unhighlight();
clearTooltips();

if ( item ) {

// Remember them so they can be removed
clickTips = [];

// Find the canvas offset
tmp = $('.graph').offset();
ofs.plotX = tmp.left;
ofs.plotY = tmp.top;

tmp = plot.getPlotOffset();
ofs.plotX += tmp.left;
ofs.plotY += tmp.top;

// For each point over the x-axis
for ( ;x<xcnt;x++) {

// Let's highlight all of them in the series
plot.highlight(item.seriesIndex, x);

// datapoints is flat, we need to know how the step size
// of each point so we can find our X/Y values
// this is the normalized value relative to the
// graph
pz = series[item.seriesIndex].datapoints.pointsize;
dp = [ series[item.seriesIndex].datapoints.points[x*pz], series[item.seriesIndex].datapoints.points[x*pz+1] ];

// This is the real value to show in the label
fmtd = series[item.seriesIndex].data[x][1];

// Convert to canvas-space and add canvas offset
ofs.pageX = parseInt(axis.xaxis.p2c(dp[0]) + ofs.plotX);
ofs.pageY = parseInt(axis.yaxis.p2c(dp[1]) + ofs.plotY);

// Create the HTML
ttip = $(toolTipHTML( fmtd, (item.dataIndex == x ? item.series.label : null) ));

// Add it to the DOM. Don't pollute the graph element.
// Add it to the parent instead.
$('.graph').parent().append(ttip);

// Figure out how much to offset the label based on its size
ofs.width = ttip.outerWidth();
ofs.height = ttip.outerHeight();

// Position it accordingly
ttip.offset({ left: ofs.pageX - ofs.width / 2, top: ofs.pageY - ofs.height - 15 });

clickTips[x] = ttip;

}
}

});



There's a working demo and full source available on my sandbox. This example can provide a starting point to a lot of different possibilities. Once you understand what Flot has available to find the position of points, adding other interactive features becomes relatively easy.