I am recently developing a package
InteractiveComplexHeatmap
which generates interactive heatmaps as Shiny apps. One basic interactivity on
heatmap is to click on heatmap cells or to select a region from it. Shiny
allows to set click
and brush
arguments in plotOutput()
to perform
clicking or brushing on the heatmap image, and on the server side, to respond
to these two actions. In InteractiveComplexHeatmap, I defined an action to
respond to click event and an action to respond to brush event. However, there is
one big problem that is brushing on heatmap is always intialized with a click
action, so when brushing on heatmap, both the response for click and brush
will be executed. This problem can be ignored by using dbclick
(double
click) instead of click
, but double click is not user-friendly.
I demonstrate this problem with the following app:
library(shiny)
library(grid)
library(glue)
library(circlize)
ui = fluidPage(
plotOutput("plot", width = 600, height = 400,
click = "click", brush = "brush"),
fluidRow(
column(3, htmlOutput("output1")),
column(3, htmlOutput("output2"))
)
)
server = function(input, output, session) {
output$plot = renderPlot({
grid.newpage()
grid.rect()
})
observeEvent(input$click, {
output$output1 = renderText({
isolate(glue("
<pre style='background-color:{rand_color(1)}'>
a click:
input$click$coords_css$x = {input$click$coords_css$x}
input$click$coords_css$y = {input$click$coords_css$y}</pre>"))
})
})
observeEvent(input$brush, {
output$output2 = renderText({
isolate(glue("
<pre style='background-color:{rand_color(1)}'>
a brush:
input$brush$coords_css$xmin = {input$brush$coords_css$xmin}
input$brush$coords_css$ymin = {input$brush$coords_css$ymin}
input$brush$coords_css$xmax = {input$brush$coords_css$xmax}
input$brush$coords_css$ymax = {input$brush$coords_css$ymax}</pre>"))
})
})
}
shinyApp(ui, server)
The plot output only contains a rectangle. Both click
and brush
are turned on. Clicking or brushing on the plot prints the coordinates.
The two output blocks (output1
and output2
) are assigned with
random colors to highlight different clicks or brushes.
As shown in following figure, every time when brushing on the plot, the output for click also changes correspondingly.
Unfortunately, plotOutput()
does not support to differentiate click
and brush
, see https://stackoverflow.com/questions/30527977/ggplot2-how-to-differentiate-click-from-brush
and https://github.com/rstudio/shiny/issues/947.
After many tries, I figured out how to differentiate click
and brush
. The
idea is simple. In JavaScript, both click and brush contain two mouse events,
mousedown
and mouseup
. mousedown
corresponds to the action when you
click into the mouse and mouseup
corresponds to the action when you release
your finger. The difference between click
and brush
is click
won’t
change the mouse positions between mousedown
and mouseup
, while brush
changes. Once we can differentiate click
and brush
, when either of the two
actions finishes, we can create or update a reactive value by
Shiny.setInputValue
in JaveScript to trigger the response in Shiny
server function.
I demonstrate this idea with the following code. When either of click
or
brush
finishes, the reactive variable action
is created or updated
by Shiny.setInputValue('action', Math.random());
. In the server function,
we do not observe input$click
or input$brush
anymore, while now we
observe input$action
. By comparing the positions of mouse, we can choose
whether to update click output or brush output.
library(shiny)
library(grid)
library(glue)
library(circlize)
ui = fluidPage(
plotOutput("plot", width = 600, height = 400,
click = "click", brush = "brush"),
tags$script(HTML("
$('#plot').mousedown(function(e) {
var parentOffset = $(this).offset();
var relX = e.pageX - parentOffset.left;
var relY = e.pageY - parentOffset.top;
Shiny.setInputValue('x1', relX);
Shiny.setInputValue('y1', relY);
}).mouseup(function(e) {
var parentOffset = $(this).offset();
var relX = e.pageX - parentOffset.left;
var relY = e.pageY - parentOffset.top;
Shiny.setInputValue('x2', relX);
Shiny.setInputValue('y2', relY);
Shiny.setInputValue('action', Math.random());
});
")),
fluidRow(
column(3, htmlOutput("output1")),
column(3, htmlOutput("output2"))
)
)
server = function(input, output, session) {
output$plot = renderPlot({
grid.newpage()
grid.rect()
})
observeEvent(input$action, {
if(input$x1 == input$x2 && input$y1 == input$y2) {
output$output1 = renderText({
isolate(glue("
<pre style='background-color:{rand_color(1)}'>
a click:
x1 = {input$x1}
y1 = {input$y1}
x2 = {input$x2}
y2 = {input$y2}
input$click$coords_css$x = {input$click$coords_css$x}
input$click$coords_css$y = {input$click$coords_css$y}</pre>"))
})
} else {
output$output2 = renderText({
isolate(glue("
<pre style='background-color:{rand_color(1)}'>
a brush:
x1 = {input$x1}
y1 = {input$y1}
x2 = {input$x2}
y2 = {input$y2}
input$brush$coords_css$xmin = {input$brush$coords_css$xmin}
input$brush$coords_css$ymin = {input$brush$coords_css$ymin}
input$brush$coords_css$xmax = {input$brush$coords_css$xmax}
input$brush$coords_css$ymax = {input$brush$coords_css$ymax}</pre>"))
})
}
})
}
shinyApp(ui, server)
As you can see the following figure, now the click and brush are independent. Brushing on the plot won’t conflict to the clicks.
Note, here the aim is to remove click event from brush, thus actually the code
for brush does not need to be changed (which means we still observe output$brush
).
We can only observe input$action
and respond to click only when the mouse
positions are unchanged (see how observeEvent(input$action, ...)
is defined in following code).
ui = fluidPage(
plotOutput("plot", width = 600, height = 400,
click = "click", brush = "brush"),
tags$script(HTML("
$('#plot').mousedown(function(e) {
var parentOffset = $(this).offset();
var relX = e.pageX - parentOffset.left;
var relY = e.pageY - parentOffset.top;
Shiny.setInputValue('x1', relX);
Shiny.setInputValue('y1', relY);
}).mouseup(function(e) {
var parentOffset = $(this).offset();
var relX = e.pageX - parentOffset.left;
var relY = e.pageY - parentOffset.top;
Shiny.setInputValue('x2', relX);
Shiny.setInputValue('y2', relY);
Shiny.setInputValue('action', Math.random());
});
")),
fluidRow(
column(3, htmlOutput("output1")),
column(3, htmlOutput("output2"))
)
)
server = function(input, output, session) {
output$plot = renderPlot({
grid.newpage()
grid.rect()
})
observeEvent(input$action, {
if(!(input$x1 == input$x2 && input$y1 == input$y2)) {
return(NULL)
}
output$output1 = renderText({
isolate(glue("
<pre style='background-color:{rand_color(1)}'>
a click:
input$click$coords_css$x = {input$click$coords_css$x}
input$click$coords_css$y = {input$click$coords_css$y}</pre>"))
})
})
observeEvent(input$brush, {
output$output2 = renderText({
isolate(glue("
<pre style='background-color:{rand_color(1)}'>
a brush:
input$brush$coords_css$xmin = {input$brush$coords_css$xmin}
input$brush$coords_css$ymin = {input$brush$coords_css$ymin}
input$brush$coords_css$xmax = {input$brush$coords_css$xmax}
input$brush$coords_css$ymax = {input$brush$coords_css$ymax}</pre>"))
})
})
}