Click and Hover Interaction in D3 (Part 1)

Updating a visualization through clicks and mouseovers

Code, Recording

  1. In the previous notes, we saw how user can supply interactions using D3 event listeners. Here, we’ll see how to design interactive visualizations that respond to mouse click and hover events.
  2. We’ll work with the movie ratings dataset, which we previously used when introducing interactivity in Shiny. The code at these links [1, 2] makes a static scatterplot for these data, without implementing any interactivity. The scatterplot points are appended using a data bind,
    d3.select("#circles")
     .selectAll("circle")
     .data(data, d => d.Title).enter()
     .append("circle")
     .attrs({
       class: "plain",
       cx: d => scales.x(d.IMDB_Rating),
       cy: d => scales.y(d.Rotten_Tomatoes_Rating),
       fill: d => scales.fill(d.Genre_Group)
     })
    

    and the axes are created using d3.axisLeft and d3.axisBottom applied to the scales,

  [g element onto which to append the x-axes]
    .call(d3.axisBottom(scales.x).ticks(4))

  [g element onto which to append the y-axes]
    .call(d3.axisLeft(scales.y).ticks(4))
  1. Let’s first consider how to implement a tooltip using hover events. A tooltip provides “detailed-on-demand” when the user indicates interest in a specific elements of a visualization. In our application, we’ll display the movie name whenever the user hovers over a point in the scatterplot. However, in principle, any pieces of information could be revealed.
  1. To implement this type of interactivity, we first listen for anytime the user hovers over a movie.
    d3.select("#circles")
     .selectAll("circle")
     .data(data, d => d.Title).enter()
     ... everything the same as before until...
     .on("mouseover", (ev, d) => mouseover(ev, d))
    

    The anonymous function (ev, d) => mouseover(ev, d) makes sure that we pass both properties of the event (ev) and the underlying data associated with the HTML element under consideration (d).

  2. Next, we update the text that will contain the movie names. Whenever a hover event occurs, we will need to (i) change the text of the tooltip and (ii) move the tooltip to the location of the current point. Note that we have already created an initial <div> element with id="tooltip" on the HTML page within which we can append the movie title.

  3. To change the text of the tooltip, we apply .text() to the current data selection. We move the tooltip using a translation of the parent div element. We also change the circle to class highlighted so that it appears larger than the surrounding circles.
    function mouseover(ev, d) {
     let loc = d3.pointer(ev)
     d3.select("#tooltip")
       .attr("transform", `translate(${loc[0]}, ${loc[1]})`)
       .select("text")
       .text(d.Title)
    
     d3.select(ev.target).attr("class", "highlighted")
    }
    
  4. If we want to display much more information about an item using hover events, it can be useful to link the hovered item with a table. This can be accomplished using a mechanism similar to a tooltip. We again track hover events and update text in a predefined HTML element (a <table> in this case). We’ve given IDs within the table that will allow us to quickly substitute titles, genres, etc. within it. In a way, this implementation is even simpler, because we don’t have to move the text to user’s mouse location.
    function mouseover(ev, d) {
     d3.select("#title").text(d.Title)
     d3.select("#genre").text(d.Genre_Group)
     d3.select("#year").text(d.Release_Date)
     d3.select("#gross").text(d.Worldwide_Gross)
     d3.select(ev.target).attr("class", "highlighted")
    }
    
  1. Next, let’s consider click events. Imagine we wanted to allow select movie genres by clicking elements of the legend, like in the visualization below,
  1. To implement this, we’ll use the same array-based logic as in our earlier select-input-based gapminder visualization. That is, we’ll keep track of an array of the currently selected genres and will enter and exit datapoints each time the array is changed.
  2. The main differences with what we implemented with the tooltip are that (i) we need to update the array based on clicks on the rectangles that form the legend and (ii) when a rectangle is clicked, we need to update not just the original plot, but also the appearance legend that defines it.
  3. To this end, we bind click listeners to the rectangles in the legend. At the start, all the legend elements are considered selected, and the associated selection array includes every genre. Whenever a rectangle is clicked, we modify the array,
d3.select("#legend .legendCells")
  .selectAll(".cell") // creates selection of the legend rectangles
  .on("click", (ev, d) => toggle_selection(ev, d))

...

function toggle_selection(ev, d) {
  let ix = selected.indexOf(d)
  if (ix == -1) {
    selected.push(d); // adds genre to the selected list
  } else {
    selected.splice(ix, 1) // removes genre from the selected list
  }
  update_view()
}
  1. After the array is modified, we then update the opacity and size of the scatterplot circles to reflect the selection,
function update_view() {
  d3.select("#circles")
    .selectAll("circle")
    .transition()
    .duration(500)
    .attrs({
      opacity: d => selected.indexOf(d.Genre) == -1 ? 0.4 : 1,
      r: d => selected.indexOf(d.Genre) == -1 ? 1 : 2
    })
  ...

  1. We also change the encoding of the associated legend rectangle and text, to let the user know that the associated genre has been added / removed from teh selection,
d3.select(".legendCells")
  .selectAll("rect")
  .attr("opacity", (d) => selected.indexOf(d) == -1 ? 0.4 : 1)
d3.select(".legendCells")
  .selectAll("text")
  .attr("opacity", (d) => selected.indexOf(d) == -1 ? 0.4 : 1)
  1. The full implementation of this example can be found here [1, 2].