Friday, 11 July 2014

My favourite tooltip method for a line graph

The following post is a portion of the D3 Tips and Tricks book which is free to download. To use this post in context, consider it with the others in the blog or just download the the book as a pdf / epub or mobi .
----------------------------------------------------------

My Favorite tooltip method for a line graph.

Purpose

Tooltips are a fabulous way to include an interactive element on a graph and a great mechanism for including additional, focused information to the user.
There are quite a number of different ways to implement tooltips (one of which you can find in the ‘Adding tooltips’ section of the ‘Assorted Tips and Tricks’ chapter of D3 Tips and Tricks) and I would be very hesitant about proclaiming any one better than another. However, the one we will work through here is my favorite when using a line graph as I think it brings a ‘fuzzier’ mechanism for deciding when a tooltip is highlighted (you don’t have to be over an object to get information on it) which I like.
I believe that the original example for this was shown by Mike Bostock here, but I first came across the technique in an example by ‘gniemetz’. I liked ‘gniemetz’s example enough to adapt a similar example which I will explain below.
The idea with this technique is to set an area the size of the graph that will be used to determine when a tooltip will be displayed. So that when the mouse enters that area, the display style that allows elements to be shown or hidden. This then tells the script to show the tooltip and the location of the mouse determines which point will have the tooltip. In the example below we can see that the mouse cursor is some distance away from the point that is being highlighted, but it is in line (in the vertical axis) with the highlighted point (in fact we will use some clever maths to determine which date point (or point on the x axis) is the one that will be used to generate the tooltip.
More complicated favorite tooltip example
To begin this explanation we’ll start with a simple example that will just project a circle on the point where the tooltip will appear. Once we’ve worked out how that works we can add whatever we want and I will explain what is going on in the more complex example.
As mentioned, we will start with a simple example that adds a circle on the point where we will place our tooltip. It will look a bit like this;
Simple version of the favorite tooltip example

The Code

The full code for this simple example is available online at bl.ocks.org or GitHub. It is also available as the files ‘best-tooltip-simple.html’ and ‘atad.csv’ as a download with the book D3 Tips and Tricks (in a zip file) when you download the book from Leanpub.
I have placed commented out asterisks besides the lines that have been added or altered from the simple graph example that we started out with at the beginning of the book so that it’s easy to see what has changed.
<!DOCTYPE html>
<meta charset="utf-8">
<style> /* set the CSS */

body { font: 12px Arial;}

path { 
    stroke: steelblue;
    stroke-width: 2;
    fill: none;
}

.axis path,
.axis line {
    fill: none;
    stroke: grey;
    stroke-width: 1;
    shape-rendering: crispEdges;
}

</style>
<body>

<!-- load the d3.js library -->    
<script src="http://d3js.org/d3.v3.min.js"></script>

<script>

// Set the dimensions of the canvas / graph
var margin = {top: 30, right: 20, bottom: 30, left: 50},
    width = 600 - margin.left - margin.right,
    height = 270 - margin.top - margin.bottom;

// Parse the date / time
var parseDate = d3.time.format("%d-%b-%y").parse;
    bisectDate = d3.bisector(function(d) { return d.date; }).left; // **

// Set the ranges
var x = d3.time.scale().range([0, width]);
var y = d3.scale.linear().range([height, 0]);

// Define the axes
var xAxis = d3.svg.axis().scale(x)
    .orient("bottom").ticks(5);

var yAxis = d3.svg.axis().scale(y)
    .orient("left").ticks(5);

// Define the line
var valueline = d3.svg.line()
    .x(function(d) { return x(d.date); })
    .y(function(d) { return y(d.close); });
    
// Adds the svg canvas
var svg = d3.select("body")
    .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
    .append("g")
        .attr("transform", 
              "translate(" + margin.left + "," + margin.top + ")");

var lineSvg = svg.append("g");                             // **********

var focus = svg.append("g")                                // **********
    .style("display", "none");                             // **********

// Get the data
d3.csv("atad.csv", function(error, data) {                 // **********
    data.forEach(function(d) {
        d.date = parseDate(d.date);
        d.close = +d.close;
    });

    // Scale the range of the data
    x.domain(d3.extent(data, function(d) { return d.date; }));
    y.domain([0, d3.max(data, function(d) { return d.close; })]);

    // Add the valueline path.
    lineSvg.append("path")                                 // **********
        .attr("class", "line")
        .attr("d", valueline(data));

    // Add the X Axis
    svg.append("g")
        .attr("class", "x axis")
        .attr("transform", "translate(0," + height + ")")
        .call(xAxis);

    // Add the Y Axis
    svg.append("g")
        .attr("class", "y axis")
        .call(yAxis);

    // append the circle at the intersection               // **********
    focus.append("circle")                                 // **********
        .attr("class", "y")                                // **********
        .style("fill", "none")                             // **********
        .style("stroke", "blue")                           // **********
        .attr("r", 4);                                     // **********
    
    // append the rectangle to capture mouse               // **********
    svg.append("rect")                                     // **********
        .attr("width", width)                              // **********
        .attr("height", height)                            // **********
        .style("fill", "none")                             // **********
        .style("pointer-events", "all")                    // **********
        .on("mouseover", function() { focus.style("display", null); })
        .on("mouseout", function() { focus.style("display", "none"); })
        .on("mousemove", mousemove);                       // **********

    function mousemove() {                                 // **********
        var x0 = x.invert(d3.mouse(this)[0]),              // **********
            i = bisectDate(data, x0, 1),                   // **********
            d0 = data[i - 1],                              // **********
            d1 = data[i],                                  // **********
            d = x0 - d0.date > d1.date - x0 ? d1 : d0;     // **********

        focus.select("circle.y")                           // **********
            .attr("transform",                             // **********
                  "translate(" + x(d.date) + "," +         // **********
                                 y(d.close) + ")");        // **********
    }                                                      // **********

});

</script>
</body>

Description

You should be able to tell from the asterisks in the code above that there aren’t too many changes and appart from a few at the start and middle, the majority are contained in a large block towards the end.
Starting with our first change
    bisectDate = d3.bisector(function(d) { return d.date; }).left;
This is our function that will be called later in the code that returns a value in our array of data that corresponds to the horizontal position of the mouse pointer. Specifically it returns the date that falls to the left of the mouse cursor.
The d3.bisector is an ‘array method’ that can use an accessor or comparator function to divide an array of objects. In this case our array of date values. In the code I have used the d3.bisector as an accessor, because I believe that it’s simpler to do so for the point of explanation, but the downside is that I had to have my dates ordered in ascending order which is why I load a slightly different csv file later (atad.csv).
If your eyes glazed over slightly reading the previous paragraph, don’t let that put you off. Like with so many things, just relax and let d3.js do the magic and remember that d3.bisector can find a value in an ordered array.
The next block of changes declares a couple of functions that we will use to add our elements to our graph;
var lineSvg = svg.append("g");

var focus = svg.append("g")
    .style("display", "none");
We will use lineSvg to add our line for the line graph and focus will add our tooltip elements. it is possible to avoid using lineSvg, but this way of declaring the functions means that we can control which elements are on top of which on the screen. For instance, it would be a pretty sad affair if our tooltip was appearing under the line of the line graph (hard to read).
As we saw earlier, our data is being sourced from a different csv file (atad.csv).
d3.csv("atad.csv", function(error, data) {
This is because we need to have it in a compatible order (ascending) to allow our bisector function to operate correctly. So while the line may look the same as the simple graph version, the data is ordered in reverse (some may say that this is the way the original data should have been presented all along, but I suppose we can’t always second guess the data we get).
We then make a small change to the script that appended the line to the graph and instead of usingsvg.append… we use our newly declared lineSvg.
    lineSvg.append("path")
        .attr("class", "line")
        .attr("d", valueline(data));
The final, larger block of code can be broken into 4 logical sections;
  1. Adding the circle to the graph
  2. Set the area that we use to capture our mouse movements
  3. The clever maths that determines which date will be highlighted
  4. Move the circle to the appropriate position
The last two points actually occur within a separate function, but for the purposes of explanation I’m happy that this is a logical division of labour for the script.
ADDING THE CIRCLE TO THE GRAPH
Adding the circle to the graph is actually fairly simple;
    focus.append("circle") 
        .attr("class", "y")
        .style("fill", "none") 
        .style("stroke", "blue")
        .attr("r", 4);
If you’ve followed any of the other examples in D3 Tips and Tricks there shouldn’t be any surprises here (well, perhaps assigning a class to the circle (y) could count as mildly unusual).
Except for one small thing….
We don’t place it anywhere on the graph! There is no x y coordinates and no translation of position. Nothing! Never fear. All we want to do at this stage is to create the element. In a few blocks of code time we will move the circle.
SET THE AREA TO CAPTURE THE MOUSE MOVEMENTS
As we briefly covered earlier, the thing that makes this particular tooltip technique different is that we don’t hover over an element to highlight the tooltip. Instead we move the mouse into an area which is relevant to the tooltip and it appears.
And its all thanks to the following code;
    svg.append("rect")
        .attr("width", width)
        .attr("height", height)
        .style("fill", "none")
        .style("pointer-events", "all")
        .on("mouseover", function() { focus.style("display", null); })
        .on("mouseout", function() { focus.style("display", "none"); })
        .on("mousemove", mousemove);
Here we’re adding a rectangle to the graph (svg.append("rect")) with the same height and width as our graph area (.attr("width", width) and .attr("height", height)) and we’re making sure that there’s no colour (fill) in it (.style("fill", "none")). Nothing too weird about all that.
Then we make sure that if any mouse events occur within the area that we capture them (.style("pointer-events", "all")). This is when things start to get interesting.
The first pointer event that we want to work with is mouseover;
        .on("mouseover", function() { focus.style("display", null); })
This line of code tells the script that when the mouse moves over the area of the rectangle of the area of the graph the display properties of the focus elements (remember that we appended our circle to focus earlier) are set to null. This might sound like a bit of a strange thing to do, since what we want to do is to make sure that when the mouse moves over the graph we want the focus elements to be displayed. but by setting the displaystyle to null the default value for display is enacted and this is inline which allows the elements to be rendered as normal. So why not use inline instead of null? Good question. I’ve tried it and it works without problem, but the original example that Mike Bostock used had the setting at null and I’ll make the assumption that Mike knows something that I don’t know about when to use null and when to use inline for a display style (maybe some browser incompatibility issues?).
The reverse of making our focus element display display everything is being able to make it stop displaying everything. This is what happens in the next line;
        .on("mouseout", function() { focus.style("display", "none"); })
Here, where the mouse moves off the area, the display properties for the focus element are turned off.
Lastly for this block, we need to capture the actions of the mouse as it moves on the graph area and move our tooltips as required. This is accomplished with the final line in the block…
        .on("mousemove", mousemove);
… where if the mouse moves we call the mousemove function.
DETERMINING WHICH DATE WILL BE HIGHLIGHTED
Once the mousemove function is called is carries out the last two steps in our code. The first of which is the clever maths that determines which point in our graph has the tooltip applied to it.
  var x0 = x.invert(d3.mouse(this)[0]),
      i = bisectDate(data, x0, 1),
      d0 = data[i - 1],
      d1 = data[i],
      d = x0 - d0.date > d1.date - x0 ? d1 : d0;
The first line of this block is a dozy;
  var x0 = x.invert(d3.mouse(this)[0]),
If we break it down the d3.mouse(this)[0] portion returns the x position on the screen of the mouse (d3.mouse(this)[1] would return the y position). Then the x.invert function is reversing the process that we use to map the domain (date) to range (position on screen). So it takes the position on the screen and converts it into an equivalent date!
for the adventurous amongst you, throw a console.log(x0); line into the mousemove function and check out the changing date/time as the cursor moves pixel by pixel (This will work for Google Chrome). Very cool.
Then we use our bisectDate function that we declared earlier to find the index of our data array that is close to the mouse cursor.
      i = bisectDate(data, x0, 1),
It takes our data array and the date corresponding to the position of or mouse cursor and returns the index number of the data array which has a date that is higher than the cursor position.
Then we declare arrays that are subsets of our data array;
      d0 = data[i - 1],
      d1 = data[i],
d0 is the combination of date and close that is in the data array at the index to the left of the cursor and d1 is the combination of date and close that is in the data array at the index to the right of the cursor. In other words we now have two variables that know the value and date above and below the date that corresponds to the position of the cursor.
The final line in this segment declares a new array d that is represents the date and close combination that is closest to the cursor.
      d = x0 - d0.date > d1.date - x0 ? d1 : d0;
It is using the magic JavaScript short hand for an if statement that is essentially saying if the distance between the mouse cursor and the date and close combination on the left is greater than the distance between the mouse cursor and the date and close combination on the right then d is an array of the date and close on the right of the cursor (d1). Otherwise d is an array of the date and close on the left of the cursor (d0).
This could be regarded as a fairly complicated little piece of code, but if you take the time to understand it, you will be surprised how elegant it appears. As we’ve seen before though, if you just want to believe that the d3.js magic is happening, that’s fine.
MOVE THE CIRCLE TO THE APPROPRIATE POSITION
The final block of code that we’ll check out takes the closest date / close combination that we’ve just worked out and moves the circle to that position;
  focus.select("circle.y")
      .attr("transform",  
            "translate(" + x(d.date) + "," +  
                           y(d.close) + ")"); 
This is a pretty easy bit of code to follow. We select the circle (using the class y that we assigned to it earlier) and then move it using translate to the date / close position that we had just worked out was the closest.
Of course this is provision of the coordinates to the circle that we noticed was missing earlier in the code when we were appending it to the graph.
And there we have it. A simple circle positioned at the closest point to the mouse cursor when the cursor hovers over the graph.
Simple version of the favorite tooltip example
If we hadn’t mentioned it earlier you might be thinking that this could possibly be the most complicated method for making most basic (read lame) tooltip ever. But you know there’s more right? Right….? Read on.

Complex version

You’ve read to this point, so that’s a sign that you’re still interested. In that case, I recommend that you take a moment to check out the live example of the graph that I’m going to describe.
More complicated favorite tooltip example
Here’s a graph that when you move your mouse over it shows the closest intersection point on the graph with lines that extend the full width of the graph (great for comparing the level across the graph) and down to the x axis (to get a rough feel for the date). As well as this there is a subtle circle around the data point in question (as already explained in the previous section) and the actual date and value represented at the intersection point. As if that wasn’t enough there is a nice little drop shadow effect under the text so that no matter what the background is you can read it. Nice.
The full code for this example is available online at bl.ocks.org or GitHub. It is also available as the files ‘best-tooltip-coolio.html’ and ‘atad.csv’ as a download with the book D3 Tips and Tricks (in a zip file) when you download the book from Leanpub.
CODE / EXPLANATION
Because the date at the tooltip needs to be formatted in a particular way we need to declare this appropriately;
    formatDate = d3.time.format("%d-%b"),
Other than that everything is pretty normal until we get to the part where we start adding elements to our focusgroup (you remember we had the circle before? Now we’re adding additional elements.).
   // append the x line
    focus.append("line")
        .attr("class", "x")
        .style("stroke", "blue")
        .style("stroke-dasharray", "3,3")
        .style("opacity", 0.5)
        .attr("y1", 0)
        .attr("y2", height);

    // append the y line
    focus.append("line")
        .attr("class", "y")
        .style("stroke", "blue")
        .style("stroke-dasharray", "3,3")
        .style("opacity", 0.5)
        .attr("x1", width)
        .attr("x2", width);

    // append the circle at the intersection
    focus.append("circle")
        .attr("class", "y")
        .style("fill", "none")
        .style("stroke", "blue")
        .attr("r", 4);

    // place the value at the intersection
    focus.append("text")
        .attr("class", "y1")
        .style("stroke", "white")
        .style("stroke-width", "3.5px")
        .style("opacity", 0.8)
        .attr("dx", 8)
        .attr("dy", "-.3em");
    focus.append("text")
        .attr("class", "y2")
        .attr("dx", 8)
        .attr("dy", "-.3em");

    // place the date at the intersection
    focus.append("text")
        .attr("class", "y3")
        .style("stroke", "white")
        .style("stroke-width", "3.5px")
        .style("opacity", 0.8)
        .attr("dx", 8)
        .attr("dy", "1em");
    focus.append("text")
        .attr("class", "y4")
        .attr("dx", 8)
        .attr("dy", "1em");
Here you can see we’re adding the x (horizontal) line and the y (vertical) line as well as the date and text values. Notice on the text values, there is a white drop shadow added first and then the text over the top. Another thing to note is that just like the position information, we don’t actually put the text in here, this is simple a ‘placeholder’ for the element.
Then all we need to do is move all the new elements to the correct position and add the changing text where appropriate;
  focus.select("circle.y")
      .attr("transform",
            "translate(" + x(d.date) + "," +
                           y(d.close) + ")");

  focus.select("text.y1")
      .attr("transform",
            "translate(" + x(d.date) + "," +
                           y(d.close) + ")")
      .text(d.close);

  focus.select("text.y2")
      .attr("transform",
            "translate(" + x(d.date) + "," +
                           y(d.close) + ")")
      .text(d.close);

  focus.select("text.y3")
      .attr("transform",
            "translate(" + x(d.date) + "," +
                           y(d.close) + ")")
      .text(formatDate(d.date));

  focus.select("text.y4")
      .attr("transform",
            "translate(" + x(d.date) + "," +
                           y(d.close) + ")")
      .text(formatDate(d.date));

  focus.select(".x")
      .attr("transform",
            "translate(" + x(d.date) + "," +
                           y(d.close) + ")")
                 .attr("y2", height - y(d.close));

  focus.select(".y")
      .attr("transform",
            "translate(" + width * -1 + "," +
                           y(d.close) + ")")
                 .attr("x2", width + width);
There’s no big surprises here. Just an extension of what we accomplished with the circle earlier. The only part that looks semi-interesting is some of the application of the positioning of the x and y lines and this is more because of the points at which the lines start and finish.
Now this is unlikely to be the end solution for most people, but at least there are plenty of examples of different elements in there to play with and experiment on.
Enjoy!

The description above (and heaps of other stuff) is in the D3 Tips and Tricks book that can be downloaded for free (or donate if you really want to :-)).

25 comments:

  1. If you look at Mike's first example that you linked to you'll see that he only applies the translation to the overall focus element.

    For your complex mouse over example this reduces the amount of code you need to write as you add extra sub-elements to the focus element, which means less bugs :)

    It does mean that the co-ordinates for all the sub-elements are relative to the focus element position.

    ReplyDelete
    Replies
    1. Fantastic. Thanks for that. So much to learn :-).

      Delete
  2. How to change date and month to only integer value?

    ReplyDelete
    Replies
    1. Sorry for the late reply. In the more complex version, the 'formatDate' declaration formats the date to show the day of the month (as an integer) and the shortened name of the month. To convert this to an integer for the month, all we need to do is to change the '%b' (abbreviated month name) for '%m' (month as a decimal number [01,12]).

      Delete
  3. In your example the tooltip is appearing on top of the line. I followed the example and my tooltip is behind the line even though I am appending it after the line in the code.

    ReplyDelete
    Replies
    1. Sorry for the really late reply. That is extremely odd. I'm note entirely sure why it would be doing this, but you could stand up an example on bl.ocks.org for demonstration purposes which might make resolving the issue easier. Conversly, I think that it might be possible if the data set you are producing is significant. In which case a queue might be in order (that is a pretty random guess, but it might be the cause) https://gist.github.com/mbostock/1696080

      Delete
  4. Hiya. Thank you so much for this site. It has become an invaluable tool in trying to get my head around d3. Quick question on this tooltip method and apologies if its me showing my inexperience but how would you go about updating the tooltips if the underlying data changes. I can't seem to work it out at all. Any help would be much appreciated. Thank you again for the amazingly helpful site

    ReplyDelete
    Replies
    1. Apologies for the really late reply. It's not you or the question. It's just that I neglect lagge blocks of questions for long periods of time and then only return to them when shame eventually kicks in :-(. Anywho... You're actually asking a question to which the answer is slightly complex. The main component to this is the dynamic changing of the data that makes up the page. to do this you will need to connect the page to the data source via a socket (http://stackoverflow.com/questions/12764813/producing-a-live-graph-with-d3) or via a regularly refreshing data set (using an update pattern (https://bl.ocks.org/mbostock/3808218) and once you have that in place, if you have your tool tip set up the data in the tool tips should update automatically.

      Delete
  5. Thanks a lot for a very comprehensive guide, learned a lot reading this.

    ReplyDelete
  6. Excellent! I search it for a while.
    I don't know how to give to keyword to search the feature of the complex version (dot-line).
    Does it have any name or somebody could give it a name?
    Anyway, thanks for this tutorial!

    ReplyDelete
    Replies
    1. I don't know either. If you like we could call it the Hsiao effect?

      Delete
  7. Awesome tutorial again sir. I left a comment on the Update viz with button click, but this tutorial is related to that question. I'm curious on how you implement this tooltip along with the update chart example found here: http://www.d3noob.org/2013/02/update-d3js-data-dynamically-button.html

    ReplyDelete
    Replies
    1. You should be able to start with this example and add the button from the other example in. I think that they have enough common code in them to be fairly interchangeable. If it doesn't work, keep experimenting by starting with the simplest example that you have that does work and then keep adding parts and experimenting as you go.

      Delete
  8. How would the code be for multi-line chart?

    ReplyDelete
    Replies
    1. Good news, this method was developed from a multi-line example here http://bl.ocks.org/gniemetz/4618602

      Delete
  9. Hey.....if we want to have ordinal scale on x-axis then it says that x.invert() is not a function...could you please help?

    ReplyDelete
    Replies
    1. Crikey. That's a tricky one. obviously the function of converting the position of the cursor to a date is (as I mentioned above) a 'doozy'. I suspect that your problem might be coming about in your equivalent of the 'bisectDate' part. You are sailing into waters that I have not seen before, so you are going to be discovering new issues that I won't have an answer to I'm afraid. All I can do is wish you luck and give the advice to make small changes often and test each one to learn. Good luck

      Delete
  10. Hey....is there any way we can fix hoverline to last value when the graph page opens?

    ReplyDelete
    Replies
    1. Hmm.... Interesting question. I don't think it could be done easily with the current code. Mainly because it relies on the mouse position to set the hover line. Hmm... On second thought you could have the last value as the default value that would be displayed if the mouse *wasn't* hovered over the graph. This would be a bit ugly when the mouse exited on the opposite side IMHO, so it would be better to have a default starting position that immediately updates as soon as the mouse comes into the frame. Doing that would be a block of work that I would be loathe to start myself, so I would have to leave it to you I'm afraid :-).

      Delete
  11. multi-line example here http://bl.ocks.org/gniemetz/4618602 is not displayed

    ReplyDelete
    Replies
    1. You are correct. It looks like the page is refusing to load the 'data.txt' file associated with the html file because it associates the call with a cross origin request (this does seem weird). The script still works (and would work in the block) if the 'd3.csv' call is made directly to 'data.txt' instead of 'https://gist.githubusercontent.com/gniemetz/4618602/raw/74eee5aff836de54d83715336b3bfd76dd8fb579/data.txt'.

      Delete
  12. Hi I'm trying to incorporate your tooltip code into the "d3.js Multi-series line chart interactive" (http://bl.ocks.org/DStruths/9c042e3a6b66048b5bd4 code). Can anyone provide some insight on how to achieve that.

    Thanks!

    ReplyDelete
    Replies
    1. Wow. That is a great bl.ock. I've never seen that one before. While I can't provide an answer, What I reccomend is to reduce the example down to its simplest core and try adding the required code from there. If it still eludes you ask a question on StackOverflow. Best of luck.

      Delete