Capture and Leverage Mouse Events on Leaflet Map

Wendy Wang
IBM Data Science in Practice
13 min readMay 13, 2021

Co-Author: Joey Gibli, Data Scientist, IBM’s Data Science and AI Elite Team

two mice, each balancing on a plant
Photo by Belinda Fewings on Unsplash

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.

a map of New York state by county with a color scale showing the area of land and water in color density per county
the Leaflet map on which we track mouse events

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 refreshed
  • input${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 in map_land_click but map_land_shape_click is not yet there. The missing of map_land_shape_click therefore indicates a off-polygon click.
  • map_land_shape_click and map_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 and map_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 but map_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:

an animation showing a user hovering over county images and clicking on county images to get the amount of water in a box near the mouse arrow and the latitude and longitude of the county in a box in a sidebar

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:

a schematic showing the flow of actions in a session — starting from moving from outside to a background map, then moving from background to a polygon, from which a user has one of three options: one, move within a polygon, two, move to another polygon, and three, move to background map

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 to FALSE, 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 the mouseover 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:

  1. In mouseout event, check if the latest geological information in mouseover is the same as rv_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.
  2. Then check mouseover event to update rv_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:

animation of a user moving a mouse over a county-level image of New York State showing a smooth hovering experience for the user with the area of water per county showing next to the arrow, with the latitude and longitude and name of the county in the sidebar

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.

animation of hover, similar to previous image, except that now there is a horizontal bar chart indicating area of water per county. when the user hovers over a county, the bar value corresponding to that county changes color

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

--

--

Wendy Wang
IBM Data Science in Practice

Machine Learning | Deep Learning | Evolutionary Psychology | Neuroscience