21 Supplying custom data

As covered in Section 17.2, it’s often useful to supply meta-information (i.e. custom data) to graphical marker(s) and use that information when responding to a event. For example, suppose we’d like each point in a scatterplot to act like a hyperlink to a different webpage. In order to do so, we can supply a url to each point (as metadata) and instruct the browser to open the relevant hyperlink on a click event. Figure 21.1 does exactly this by supplying urls to each point in R through the customdata attribute and defining a custom JS event to window.open() the relevant url upon a click event. In this case, since each point represents one row of data, the d.point is an array of length 1, so we may obtain the url of the clicked point with d.points[0].customdata.

library(htmlwidgets)

p <- plot_ly(mtcars, x = ~wt, y = ~mpg) %>%
  add_markers(
    text = rownames(mtcars),
    customdata = paste0("http://google.com/#q=", rownames(mtcars))
  )
  
onRender(
  p, "
  function(el) {
    el.on('plotly_click', function(d) {
      var url = d.points[0].customdata;
      window.open(url);
    });
  }
")

FIGURE 21.1: Attaching hyperlinks to each point in a scatterplot and using a custom JS event to open that Google search query upon clicking a point. For the interactive, see https://plotly-r.com/interactives/click-open.html

In addition to using window.open() to open the url, we could also add it to the plot as an annotation using the plotly.js function Plotly.relayout(), as done in Figure 21.2. Moreover, since plotly annotations support HTML markup, we can also treat that url as a true HTML hyperlink by wrapping it in an HTML <a> tag. In cases where your JS function starts to get complex, it can help to put that JS function in its own file, then use the R function readLines() to read it in as a string and pass along onRender() as done below:

onRender(p, readLines("js/hover-hyperlink.js"))

Click to show the ‘js/hover-hyperlink.js’ file

// Start of the hover-hyperlink.js file
function(el) {
  el.on('plotly_hover', function(d) {
    var url = d.points[0].customdata;
    var ann = {
      text: "<a href='" + url + "'>" + url + "</a>",
      x: 0,
      y: 0,
      xref: "paper",
      yref: "paper",
      yshift: -40,
      showarrow: false
    };
    Plotly.relayout(el.id, {annotations: [ann]});
 });
}

FIGURE 21.2: Using Plotly.relayout() to add and change hyperlink in response to hover events. For the interactive, see https://plotly-r.com/interactives/hover-annotate.html

When using Plotly.relayout(), or any other plotly.js function to modify a plot, you’ll need to know the id attribute of the relevant DOM instance that you want to manipulate. When working with a single object, you can simply use el.id to access the id attribute of that DOM instance. However, when trying to target another object, it gets trickier because id attributes are randomly generated by htmlwidgets. In that case, you likely want to pre-specify the id attribute so you can reference it client-side. You can pre-specify the id for any htmlwidgets object, say widget, by doing widget$elementId <- “myID”.

The customdata attribute can hold any R object that can be serialized as JSON, so you could, for example, attach complex data to markers/lines/text/etc using base64 strings. This could be useful for a number of things such as displaying an image on hover or click. For security reasons, plotly.js doesn’t allow inserting images in the tooltip, but you can always define your own tooltip by hiding the tooltip (hoverinfo='none'), then populating your own tooltip with suitable manipulation of the DOM in response to "plotly_hover"/"plotly_unhover" events. Figure 21.3 demonstrates how to leverage this infrastructure to display a png image in the top-left corner of a graph whenever a text label is hovered upon.39

x <- 1:3 
y <- 1:3
logos <- c("r-logo", "penguin", "rstudio")
# base64 encoded string of each image
uris <- purrr::map_chr(
  logos, ~ base64enc::dataURI(file = sprintf("images/%s.png", .x))
)
# hoverinfo = "none" will hide the plotly.js tooltip, but the 
# plotly_hover event will still fire
plot_ly(hoverinfo = "none") %>%
  add_text(x = x, y = y, customdata = uris, text = logos) %>%
  htmlwidgets::onRender(readLines("js/tooltip-image.js"))

Click to show the ‘js/tooltip-image.js’ file

// Start of the tooltip-image.js file
// inspired, in part, by https://stackoverflow.com/a/48174836/1583084
function(el) {
  var tooltip = Plotly.d3.select('#' + el.id + ' .svg-container')
    .append("div")
    .attr("class", "my-custom-tooltip");

  el.on('plotly_hover', function(d) {
    var pt = d.points[0];
    // Choose a location (on the data scale) to place the image
    // Here I'm picking the top-left corner of the graph
    var x = pt.xaxis.range[0];
    var y = pt.yaxis.range[1];
    // Transform the data scale to the pixel scale
    var xPixel = pt.xaxis.l2p(x) + pt.xaxis._offset;
    var yPixel = pt.yaxis.l2p(y) + pt.yaxis._offset;
    // Insert the base64 encoded image
    var img = "<img src='" +  pt.customdata + "' width=100>";
    tooltip.html(img)
      .style("position", "absolute")
      .style("left", xPixel + "px")
      .style("top", yPixel + "px");
    // Fade in the image
    tooltip.transition()
      .duration(300)
      .style("opacity", 1);
  });

  el.on('plotly_unhover', function(d) {
    // Fade out the image
    tooltip.transition()
      .duration(500)
      .style("opacity", 0);
  });
}

FIGURE 21.3: Displaying an image on hover in a scatterplot. For the interactive, see https://plotly-r.com/interactives/tooltip-image.html

It’s worth noting that the JavaScript that powers Figure 21.3 works for other cartesian charts, even heatmap (as shown in Figure 21.4), but it would need to be adapted for 3D charts types.

plot_ly(hoverinfo = "none") %>%
  add_heatmap(
    z = matrix(1:9, nrow = 3), 
    customdata = matrix(uris, nrow = 3, ncol = 3)
  ) %>%
  htmlwidgets::onRender(readLines("js/tooltip-image.js"))

FIGURE 21.4: Displaying an image on hover in a heatmap. For the interactive, see https://plotly-r.com/interactives/tooltip-image-heatmap.html

On the JS side, the customdata attribute is designed to support any JS array of appropriate length, so if you need to supply numerous custom values to particular marker(s), list-columns in R provides a nice way to do so. Figure 21.5 leverages this idea to bind both the city and sales values to each point along a time series and display those values on hover. It also demonstrates how one can use the graphical querying framework from Section 16.1 in tandem with a custom JS event. That is, highlight_key() and highlight() control the highlighting of the time series, while the custom JS event adds the plot annotation (all based on the same "plotly_hover" event). In this case, the highlighting, annotations, and circle shapes are triggered by a "plotly_hover" event and they all work in tandem because event handlers are cumulative. That means, if you wanted, you could register multiple custom handlers for a particular event.

library(purrr)

sales_hover <- txhousing %>%
  group_by(city) %>%
  highlight_key(~city) %>%
  plot_ly(x = ~date, y = ~median, hoverinfo = "name") %>%
  add_lines(customdata = ~map2(city, sales, ~list(.x, .y))) %>%
  highlight("plotly_hover")

onRender(sales_hover, readLines("js/tx-annotate.js"))

Click to show the ‘js/tx-annotate.js’ file

// Start of the tx-annotate.js file
function(el) {
  el.on("plotly_hover", function(d) {
    var pt = d.points[0];
    var cd = pt.customdata;
    var num = cd[1] ? cd[1] : "No";
    var ann = {
      text: num + " homes were sold in "+cd[0]+", TX in this month",
      x: 0.5,
      y: 1,
      xref: "paper",
      yref: "paper",
      xanchor: "middle",
      showarrow: false
    };
    var circle = {
      type: "circle",
      xanchor: pt.x,
      yanchor: pt.y,
      x0: -6,
      x1: 6,
      y0: -6,
      y1: 6,
      xsizemode: "pixel",
      ysizemode: "pixel"
    };
    Plotly.relayout(el.id, {annotations: [ann], shapes: [circle]});
  });
}

FIGURE 21.5: Combining the graphical querying framework from 16.1 with a custom JS event handler to highlight a time series as well as circling the month selected. This example supplies a list-column to customdata in order to populate an informative title based on the user’s selection of city and month. For the interactive, see https://plotly-r.com/interactives/tx-annotate.html

Sometimes supplying and accessing customdata alone is not quite enough for the task at hand. For instance, what if we wish to add the average monthly sales to the annotation for the city of interest in Figure 21.5? In cases like this, we may need to use customdata to query a portion of the plot’s input data, like Figure 21.5 does to compute and display average sales for a given city. This implementation leverages the fact that each selected point (pt) contains a reference to the entire trace it derives from (pt.data). As discussion behind Figure 3.2 noted, this particular plot has a single trace and uses missing values to create separate lines for each city. As a result, pt.data.customdata contains all the customdata we supplied from the R side, so to get all the sales for a given city, we first need to filter that array down to only the elements that are belong to that city (while being careful of missing values!).

onRender(sales_hover, readLines("js/tx-mean-sales.js"))

Click to show the ‘js/tx-mean-sales.js’ file

// Start of the tx-mean-sales.js file
function(el) {
  el.on("plotly_hover", function(d) {
    var pt = d.points[0];
    var city = pt.customdata[0];

    // get the sales for the clicked city
    var cityInfo = pt.data.customdata.filter(function(cd) {
      return cd ? cd[0] == city : false;
    });
    var sales = cityInfo.map(function(cd) { return cd[1] });

    // yes, plotly bundles d3 which you can access via Plotly.d3
    var avgsales = Math.round(Plotly.d3.mean(sales));

    // Display the mean sales for the clicked city
    var ann = {
      text: "Mean monthly sales for " + city + " is " + avgsales,
      x: 0.5,
      y: 1,
      xref: "paper",
      yref: "paper",
      xanchor: "middle",
      showarrow: false
    };
    Plotly.relayout(el.id, {annotations: [ann]});
  });
}

FIGURE 21.6: Displaying the average monthly sales for a city of interest on hover. This implementation supplies all the raw sales figures, then uses the hovered customdata value to query sales for the given city and display the average. For the interactive, see https://plotly-r.com/interactives/tx-mean-sales.html

Figure 21.7 uses the same customdata supplied to Figure 21.6 in order to display a histogram of monthly sales for the relevant city on hover. In addition, it displays a vertical line on the histogram to reflect the monthly sales for the point closest to the mouse cursor. To do all this efficiently, it’s best to add the histogram trace on the first hover event using Plotly.addTraces(), then supply different sales data via Plotly.restyle() (generally speaking, restyle() is way less expensive than addTraces()). That’s why the implementation leverages the fact that the DOM element (el) contains a reference to the current graph data (el.data). If the current graph has a trace with type of histogram, then it adds a histogram trace; otherwise, it supplies new x values to the histogram.

sales_hover %>%
  onRender(readLines("js/tx-annotate.js")) %>%
  onRender(readLines("js/tx-inset-plot.js"))

Click to show the ‘js/tx-inset-plot.js’ file

// Start of the tx-inset-plot.js file
function(el) {
  el.on("plotly_hover", function(d) {
    var pt = d.points[0];
    var city = pt.customdata[0];

    // get the sales for the clicked city
    var cityInfo = pt.data.customdata.filter(function(cd) {
      return cd ? cd[0] == city : false;
    });
    var sales = cityInfo.map(function(cd) { return cd[1] });

    // Collect all the trace types in this plot
    var types = el.data.map(function(trace) { return trace.type; });
    // Find the array index of the histogram trace
    var histogramIndex = types.indexOf("histogram");

    // If the histogram trace already exists, supply new x values
    if (histogramIndex > -1) {

      Plotly.restyle(el.id, "x", [sales], histogramIndex);

    } else {

      // create the histogram
      var trace = {
        x: sales,
        type: "histogram",
        marker: {color: "#1f77b4"},
        xaxis: "x2",
        yaxis: "y2"
      };
      Plotly.addTraces(el.id, trace);

      // place it on "inset" axes
      var x = {
        domain: [0.05, 0.4],
        anchor: "y2"
      };
      var y = {
        domain: [0.6, 0.9],
        anchor: "x2"
      };
      Plotly.relayout(el.id, {xaxis2: x, yaxis2: y});

    }

    // Add a title for the histogram
    var ann = {
      text: "Monthly house sales in " + city + ", TX",
      x: 2003,
      y: 300000,
      xanchor: "middle",
      showarrow: false
    };
    Plotly.relayout(el.id, {annotations: [ann]});

    // Add a vertical line reflecting sales for the hovered point
    var line = {
      type: "line",
      x0: pt.customdata[1],
      x1: pt.customdata[1],
      y0: 0.6,
      y1: 0.9,
      xref: "x2",
      yref: "paper",
      line: {color: "black"}
    };
    Plotly.relayout(el.id, {'shapes[1]': line});
  });
}

FIGURE 21.7: Adding another event handler to Figure 21.5 to draw an inset plot showing the distribution of monthly house sales. For the interactive, see https://plotly-r.com/interactives/tx-inset-plot.html


  1. As long as your not allowing down-stream users to input paths to the input files (e.g., in a shiny app), you shouldn’t need to worry about the security of this example↩︎