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);
});
}
")
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
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]});
});
}
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.35
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
// 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);
});
}
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"))
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
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]});
});
}
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
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.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
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, just 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});
});
}
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↩