Capture and Leverage Mouse Events on Leaflet Map
Co-Author: Joey Gibli, Data Scientist, IBM’s Data Science and AI Elite Team
User interaction is central to any dashboard, but developing interactive features can be difficult. By the end of this tutorial, you will learn how to leverage mouse events on a Leaflet map in your Shiny dashboards.
User interaction typically leads to follow-up actions, such as displaying a tooltip on a user click. However, even if there is no follow-up action, keeping track of mouse events can serve as a useful way to monitor users to inform how they utilize the dashboard, such as, how much time on average do people spend on looking at a county on a map?
Generally speaking, there are two types of mouse events I find useful to keep track of:
- Click: which polygon did the user just click on, or did they click on an empty part of the map?
- Move: where is the mouse in real time, or on which polygon is it hovering?
I will walk you through the build-in tracker for each mouse event in this blog and cover how to use reactive values. The example application can be found here, and its code here.
This blog uses the same data as the previous blog (Center Diverging colors on Leaflet Map) that contains the area of land and area of water for each county in the United States. We will create a Leaflet map with basic tiles and overlay county polygons for the selected state.
Reactive Values
Reactive Values are very similar to regular variables in R (except for slightly different syntax). They enable a feature of Shiny that you will fall in love with: reactivity. A reactive value sitting inside of a reactive output is like two gears coupled together. When you turn the gear of the reactive value, changes automatically update the reactive output.
An example of a reactive value is input
. Say that you have a drop-down list of column names and an output that renders a plot with the parameter chosen from the drop-down list. With input
, whenever the user makes a different choice in the drop-down list, the reactive input is updated to that value. The code that generates the output plot is notified to re-execute with the selected parameter and the UI is updated. All you have to do is reference input
in the code for rendering the plot, extract the value, and pass it to the plot function.
The only downside to input
is that it’s not changeable from the server section directly. This makes some ideas more difficult to implement. For example, once I wanted to have a tabBox
with two tabs, one shows a world map and the other a local map. When users moved the mouse over the polygon of a country, they could click a “More Details” link in the tooltip and then be redirected to the second tab seeing local information for this country. However, since input
is immutable in the server section, you cannot modify which local region the secondary tab shows by updating the input
parameter used to define this information.
Nevertheless, input
tracks a lot of useful information to work your magic, and the mouse event on Leaflet maps is one of them.
Before peeking into the mouse events, let’s first set up a few reactive values in the server section of our app to help gather useful information.
rv_shape <- reactiveVal(FALSE)
rv_location <- reactiveValues(id=NULL,lat=NULL,lng=NULL)
We use rv_shape
to store a flag indicating whether the mouse event we want to track happens on a polygon or the background map, and rv_location
to store a set of geological information including county name (id) and latitude/longitude. reactiveVal
is for a single value while reactiveValues
is more like a list in R.
Mouse Click Events
Mouse click is an obvious signal of user’s interest. If the user clicks a county polygon, we could display information in other widgets that feature the chosen county. If a user clicks outside in the background map, we can choose to have a global summary.
Two types of click events are tracked in input
for Leaflet maps:
input${map_id}_click
: Regardless of the location, as long as a click happens on the map, the information in this event is refreshedinput${map_id}_shape_click
/input${map_id}_marker_click
/ …: Object-specific (also called layer-specific) click events, the information of which is only refreshed if the click happens on the object (e.g., clicks on polygons are tracked in shape click, clicks on markers are tracked in marker click)
Here is an example of what geological information is tracked:
Note that id
refers to the layer id that you need to manually configure when adding map layers. That being said, you can only get this information from layer-specific events. We will go over how that can be set up and what issues you might hit later. For now let’s assume we have the id
nicely set up and it contains state code and county name, separated by |
.
Tracking these event already allows us to do something cool. For example, we can display the longitude and latitude you just clicked (my map_id is map_land
) in a box
that contains a htmlOutput
called ui_county
:
## output: geo info of the mouse event
output$ui_county <- renderUI({
HTML(paste(h4(rv_location$id),
h5('latitude:',location_info$lat),
h5('longitude:',location_info$lng)))
})## when any click happens
observeEvent(input$map_land_click,{
map_land_shape_click_info <- input$map_land_shape_click
map_land_click_info <- input$map_land_click
rv_location$id <- str_split_fixed(map_land_shape_click_info$id,'\\|',2)[2]
rv_location$lat <- round(map_land_click_info$lat, 4)
rv_location$lng <- round(map_land_click_info$lng, 4)
})
In the output of ui_county
, we simply print out the geological information collected in rv_location
. When any component in rv_location
is updated, this output will be refreshed.
What should be done when the user clicks the map? Using observeEvent
we can monitor the map_land_click
event. If the user moves the mouse and does a click anywhere on the Leaflet map, the value in map_land_click
is most likely updated, which triggers the code in this block.
We first coerce both map_land_click
and map_land_shape_click
from reactiveValues
into a list so that we can easily manipulate them. We use the id
part of map_land_shape_click
, split this string by |
the separator, and extract the county name (second element) to be assigned to rv_location$id
. Note that this information can only be found in a layer-specific event. Also, we save the longitude and latitude from map_land_click
to rv_location
.
Now when we initialize the Shiny app, this box will show nothing for the three types of information. If we click on anywhere outside of the polygons, the longitude and latitude will be refreshed and show where we just clicked. If we click on any polygon, the corresponding county name will appear with longitude and latitude refreshed as well.
But wait a moment, this doesn’t look too good… Once a click has happened on a county, even if we actually click on somewhere outside the polygons, the text still shows the previous county name, which is misleading. This happens because a click on the background map doesn’t reset map_land_shape_click
event. It contains the old information and gets no update, hence our rv_location$id
still gets the out-dated county name.
To address this issue, we will create a flag indicates whether the click happens. We can either reset the value for rv_location$id
, or tweak ui_county
so that it doesn’t use rv_location$id
when the click is off polygon. Here I will show how to do the latter.
Looking at the information in map_land_click
and map_land_shape_click
, there are three situations when a click event happens (no matter where, i.e., when map_land_click
is refreshed):
map_land_shape_click
doesn’t exist: This happens when the user clicks on somewhere in the background map before polygons, which creates and refreshes the information inmap_land_click
butmap_land_shape_click
is not yet there. The missing ofmap_land_shape_click
therefore indicates a off-polygon click.map_land_shape_click
andmap_land_click
share the same pair of longitude and latitude: When the click is on a polygon, both events get refreshed and the geological information will be the same. If we check the longitude and latitude from these two events and find them exactly the same, that indicates an on-polygon click.map_land_shape_click
andmap_land_click
doesn’t share the same pair of longitude and latitude: When one click was on a polygon and the second on the background map,map_land_click
gets a new pair of longitude and latitude butmap_land_shape_click
doesn’t. This difference as a result indicates an off-polygon click.
## when any click happens
observeEvent(input$map_land_click,{
map_land_shape_click_info <- input$map_land_shape_click
map_land_click_info <- input$map_land_click
if (is.null(map_land_shape_click_info)){
rv_shape(FALSE)
}else if (!all(unlist(map_land_shape_click_info[c('lat','lng')]) == unlist(map_land_click_info[c('lat','lng')]))){
rv_shape(FALSE)
}else{
rv_shape(TRUE)
}
rv_location$id <- str_split_fixed(map_land_shape_click_info$id,'\\|',2)[2]
rv_location$lat <- round(map_land_click_info$lat, 4)
rv_location$lng <- round(map_land_click_info$lng, 4)
})
Now we just need to modify ui_county
a little bit to leverage this rv_shape
flag:
output$ui_county <- renderUI({
location_info <- reactiveValuesToList(rv_location)
if (rv_shape()){
HTML(paste(h4(rv_location$id),
h5('latitude:',location_info$lat),
h5('longitude:',location_info$lng)))
}else{
HTML(paste(h4(input$select_state),
h5('latitude:',location_info$lat),
h5('longitude:',location_info$lng)))
}
})
When the most recent click is on a county, we display the county name, otherwise we show the state name.
With a few adjustments on the UI, it looks like this:
Mouse Move Events
You may find clicks too intentional to give a smooth user experience, so you turn to capturing mouse move events. No problem — eh, actually, not too much of a problem. The built-in events are not as comprehensive as the ones for mouse clicks, but they still provide quite a lot of information.
You will see two types of move events:
input${map_id}_shape_mouseover
etc.: Object/layer-specific event that is triggered when the mouse enters a polygon, i.e., moves from outside of a polygon into this polygon (or other objects). It can be from the background map to a polygon, or from one polygon to another, but the information won’t be refreshed if the mouse moves within the polygon.input${map_id}_shape_mouseout
etc.: Object/layer-specific event that is triggered when the mouse leaves a polygon (or other objects), i.e., moves from one polygon to somewhere outside of it. It can be from the polygon to the background map, or to another polygon, but the information won’t be refreshed if the mouse moves from the background map into a polygon.
Combining the information from both mouse move events, we can identify which polygon the mouse is on or if the mouse is on the background map, as well as the longitude and latitude of the mouse location when either event is triggered. It won’t give information such as longitude and latitude of the mouse position on the background map (tracking ends when the mouse leaves the boundary), or longitude and latitude of the mouse position in real time within a polygon (mouse moves within a polygon won’t trigger either of the events and hence the information can’t be logged).
If the mouse moves onto a polygon, the geological location can be easily captured in a similar way to click events:
## track mouseover events
observeEvent(input$map_land_shape_mouseover, {
map_land_shape_mouseover_info <- input$map_land_shape_mouseover
rv_location$id <- str_split_fixed(map_land_shape_mouseover_info$id,'\\|',2)[2]
rv_location$lat <- round(map_land_shape_mouseover_info$lat, 4)
rv_location$lng <- round(map_land_shape_mouseover_info$lng, 4)
})
With the above code, when the map_land_shape_mouseover
event is triggered, we extract the geological information and assign it to rv_location
.
Likewise, what becomes a little tricky is how to differentiate whether the mouse is on a polygon or on the background map.
In mouse click events, we have a general click event that tracks the longitude and latitude information anywhere on the map and a specific click event that only tracks such information on target objects/layers. This allows us to tell if the click just happened on a object/layer or the background map by checking the consistency. However, in mouse move events we don’t have a general move event to compare against map_land_shape_mouseover
.
Let’s think thoroughly about what would happen to these two events when we move the mouse. The flow of the actions and corresponding changes in these two events is summarized below:
On this flow chart, it is quite clear that there are two scenarios when the mouse is on the background map:
- When the user moves the mouse from outside of Leaflet onto the background map: In this case neither event is created. We can simply default the
rv_shape
flag toFALSE
, assuming that by default the mouse is not on any polygon, which takes this scenario into consideration. - When the user moves the mouse from a polygon to the background map: In this case, we see the
mouseout
event triggered, but not themouseover
event, and this status is unique and can be used as an identifier.
To handle the second scenario, we can create an observeEvent
monitoring input$map_land_shape_moveout
. According to our summary, this event will be triggered either when the user moves the mouse from one polygon to another (mouseover
triggered) or from the current polygon to the background map (mouseover
not triggered).
In order to be able to tell whether the mouseover
event is triggered simultaneously or not, given that when an event is triggered the geological information stored in it is most likely changed, we could store the geological location of the previous mouseover
event before a new one is triggered. By saying “most likely changed”, there is a faint chance that it doesn’t, for example you move from one county to the background map then back to the same county at exactly the same longitude and latitude from where you left — the chance is too small to be worth a headache.
## initialize reactive values
rv_location_move_old <- reactiveValues(lat=NULL,lng=NULL)
First, we need rv_location_move_old
to store the old moveover
event. Then we find a time to update it. The question is, when should we update the record?
A solution you may think of is to always update this value when the mouseover
event is triggered, so that this variable would contain up-to-date event. You want the sequence of executions to be like this:
- In
mouseout
event, check if the latest geological information inmouseover
is the same asrv_location_move_old
. If yes, you can claim the mouse just moved out onto the background map, and otherwise it moved out onto a different county. - Then check
mouseover
event to updaterv_location_move_old
if the event is triggered.
You would like to make the update of rv_location_move_old
always occurs later than the check of mouseout
event so it allows you time to do the comparison first. Otherwise, you will always see rv_location_move_old
having exactly the same value as the latest mouseover
event, which makes it useless.
While that sounds very intuitive and may actually work, it is not a robust enough approach to solving this problem. Reactivity in Shiny means a completely different way to select what code to execute. The order in which you write down mutually independent blocks, here observeEvent(input$map_land_shape_mouseover,{...})
and observeEvent(input$map_land_shape_mouseout,{...})
, does not reflect the order in which they will be executed. It is safer to think that the sequence to execute these mutually independent blocks is random when you write the code.
Don’t be sad. Without the help of sequenced executions, we can still come up with a workaround.
## track mouseover events
observeEvent(input$map_land_shape_mouseover, {
map_land_shape_mouseover_info <- input$map_land_shape_mouseover
rv_shape(TRUE)
rv_location$id <- str_split_fixed(map_land_shape_mouseover_info$id,'\\|',2)[2]
rv_location$lat <- round(map_land_shape_mouseover_info$lat,4)
rv_location$lng <- round(map_land_shape_mouseover_info$lng,4)
})## track mouseout events
observeEvent(input$map_land_shape_mouseout, {
map_land_shape_mouseover_info <- input$map_land_shape_mouseover
map_land_shape_mouseover_info_old <- reactiveValuesToList(rv_location_move_old)
if (all(unlist(map_land_shape_mouseover_info[c('lat','lng')]) == unlist(map_land_shape_mouseover_info_old[c('lat','lng')]))){
rv_shape(FALSE)
rv_location$lat <- 'not tracked (outside of the state)'
rv_location$lng <- 'not tracked (outside of the state)'
}else{
rv_location_move_old$lat <- map_land_shape_mouseover_info$lat
rv_location_move_old$lng <- map_land_shape_mouseover_info$lng
}
})
An update of the moveover
event indicates the mouse staying on a polygon, so we can always assign TRUE
to rv_shape
when this event happens.
With regard to mouseout
, we compare the longitude and latitude recorded in the latest mouseover
event with the ones in rv_location_move_old
. When the values are not the same, this is a move to another polygon, and we do nothing but update this rv_location_move_old
with the new location. When the values are not consistent, we turn off the rv_shape
flag and update the longitude and latitude in rv_location
with some explanation.
You will get something like the following:
Example of Usage
At the end of the day, you might ask this question: after going through so much, what can I do with this information?
That’s a good point. Not too many people would feel great just looking at these county names and geological information. These information would be more meaningful if tied to other data or visualization that you intend to display.
During my work with R Shiny, especially when a Leaflet map is needed, the map is usually complemented by other information like plots or tables. I always find it useful leveraging mouse clicks or moves to highlight corresponding information in other widgets.
Here we can have a bar plot to visualize the area of land of each county, ranked by the size. Whenever the mouse clicks on a county, we highlight the corresponding bar in the bar plot.
It’s going to be very similar to what you would usually do to create an interactive bar plot. You would create a ggplot
, then throw it in a ggplotly
function. Now the only difference is that we will assign colors to each county based on whether it’s in rv_location
or not. The plot will be in a plotlyOutput
called bar_state
.
output$bar_state <- renderPlotly({
if (!rv_shape()){
df_plot <- rvs$poly_state@data %>% mutate(Color='1')
}else{
df_plot <- rvs$poly_state@data %>% mutate(Color=ifelse(NAME==rv_location$id,'2','1'))
}
p <- df_plot %>%
mutate(NAME=reorder(NAME,ALAND)) %>%
top_n(50,ALAND) %>%
ggplot(aes(x=NAME,y=ALAND)) +
geom_bar(aes(fill=Color,text=paste('Area of Land:',ALAND)),stat="identity") +
coord_flip() +
scale_fill_manual(values=c('1'="#5aae61", '2'="#fdc086")) +
scale_y_continuous(labels = comma) +
theme(legend.position="none",
axis.text.y = element_text(size=6),
axis.text.x = element_text(size=6),
axis.title.x = element_blank(),
axis.title.y = element_blank()
)
p %>%
ggplotly(tooltip = "text")
})
We pick out only the top 50 counties in a state (some states have over 100!) so that the plot won’t be too crowded, flip the coordinate to create horizontal bar plot, format the numbers with comma, remove the legend, make the font size smaller, and clean up the labels on both x and y axis. All regular practices that you may take for a ggplot
chart, nothing special.
As easy as it seems, you will have this nice bar chart that dynamically responses to the county your mouse clicks or stays on.
You must be very patient to read through this long blog! I hope this familiarizes you with these two types of built-in mouse events in R Shiny + Leaflet, and helps you build your own fantastic Shiny application.
More Information
Documentation on Leaflet’s input events in Shiny: http://rstudio.github.io/leaflet/shiny.html#inputsevents