Interactive Map Filter in Shiny
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
Introduction
Recently, I participated in Posit’s 2024 Table Contest. For my submission, which you can view here, I included a leaflet map that acts as a filter in Shiny. This is a cool, dashboard-like feature similar to what you might find in Power BI. I recreated this effect and learned a bit through the process.
I first saw this wonderful blog post by Nathan Day but realized didn’t exactly match the feel I was going for. I adapted his code and added my own preferences (specifically allowing the input map to select multiple polygons and resetting the output table when polygons were “unclicked”). I wanted to share a basic example for others who might want to try this out!
Example Data
The data I will be using for this example can be queried using the CDCPLACES package (see more on GitHub). I will take a sample of county data from the State of Ohio. Here I am filtering only the age-adjusted rates and the measure “ACCESS2” which is the percentage of the population aged 18-64 that lack health insurance. I will also set the CRS for the data using sf::st_transform to avoid warnings when the data is queried.
Code
library(leaflet) library(shiny) library(CDCPLACES) library(dplyr) ohio <- get_places(state = "OH", measure = "ACCESS2", geometry = TRUE) |> filter(datavaluetypeid == "AgeAdjPrv") |> select(year, stateabbr, locationname, measure, data_value, geometry) |> sf::st_transform(crs = 4326)
UI
Next, we can get into the UI side of our demo app. This is fairly straightforward. We initiate a fluid page, a title, and a sidebar layout. The sidebar has our leaflet map as a filter. In the main panel, we will output a data table.
I have added a tags$head function to add some custom CSS to the app. This is an optional step, but these two options make the panel transparent, which I think adds a lot to the look and feel of the app.
Code
ui <- fluidPage(
tags$head(
tags$style(HTML(".leaflet-container { background: none; }
.well { background: none;}"))
),
titlePanel("My Demo App"),
sidebarLayout(
sidebarPanel(
leafletOutput("mapfilter", height = 250)
),
mainPanel(
DT::DTOutput("table")
)
)
)
Server
Now we can specify the logic of the server to get the result we want. To start we can initialize a few reactive values. This will allow us to update our filtered data and what is displayed on the map. selected_counties will correspond to what is highlighted on the map when we click, filtered_data will be the data frame that is displayed on the main table output.
Code
server <- function(input, output, session) {
# Initialize reactive values
rv <- reactiveValues(selected_counties = NULL,
filtered_data = ohio) # Initialize reactive values
}
The following code chunks are wrapped within the server function call.
Outputs
This section will briefly describe the functions for our outputs: the map filter and the table.
Table
This chunk defines the output corresponding to the id table, and renders a datatable. We input the reactive value of our filtered data with rv$filtered_data, remove the geometry with sf::st_set_geometry(NULL), and send it to DT::datatable() for a simple table display.
Code
output$table <- DT::renderDT({
rv$filtered_data |>
sf::st_set_geometry(NULL) |>
DT::datatable()
})
Map
For our map, we follow similar steps. We use the base data frame ohio to create our map. Future steps will show how we update this with our click behavior. highlightOptions here defines how the map reacts to hovering over polygons. It will fill the county the mouse is hovering over.
Code
output$mapfilter <- renderLeaflet({ # rendering the filter map
leaflet(ohio, # initializing the map
options = leafletOptions(
zoomControl = FALSE,
dragging = FALSE,
minZoom = 6,
maxZoom = 6
)) |> # then add polygons
addPolygons(layerId = ~locationname,
label = ~locationname,
col = "black",
fillColor = "steelblue",
weight = 2,
fillOpacity = .1,
highlight = highlightOptions(
fillOpacity = 1,
bringToFront = TRUE
))
})
Click Behavior
Next, we will define our behavior when the map is clicked. We can break this into two parts, updating the data that is fed into the output table, and changing the display of the input map.
The code chunk below runs when a polygon on our map is clicked. That is the logic of the observeEvent function and its argument input$mapfilter_shape_click. Because our actions all relate to this event, we can wrap all of our code in it. The other step here is to store the input in an object called click.
Code
observeEvent(input$mapfilter_shape_click, {
# this is the logic behind the "click" of the map.
click <- input$mapfilter_shape_click
})
If we were to simply print(click) we would see the following output upon an initial click and a second click of the same polygon:
This will inform how we use the input to update our data and map.
We can use a set of if and else statements to store data from click in our reactive values.
The first statement checks to see if the current
click$idexists inrv$selected_counties. If it does, it will remove it from the vector.The next statement checks to see if the
click$idis equal to “selected”. Recall that this occurs when the same polygon is selected twice in a row. If this condition is met, we will filterrv$selected_countiesby removing the last value in the length of the vector.Lastly, if the other two conditions are not met, the new and unique
click$idis added torv$selected_counties.
Code
if (click$id %in% rv$selected_counties) {
# If selected, remove it
rv$selected_counties <-
rv$selected_counties[rv$selected_counties != click$id]
} else if(click$id == "selected"){
# when a county is clicked again it is removed
rv$selected_counties <-
rv$selected_counties[rv$selected_counties !=
tail(rv$selected_counties, n = 1)]
}else { # If not selected, add it
rv$selected_counties <- c(rv$selected_counties, click$id)
}
Then we have an update to our map. We can accomplish this with leafletProxy. We will simply add an ifelse function to the argument fillOpacity. This ensures that counties present in our rv$selected_counties will have the proper fill.
Code
leafletProxy("mapfilter", session) |>
addPolygons(data = ohio,
layerId = ~locationname,
label = ~locationname,
fillColor = "steelblue",
col = "black",
weight = 2,
fillOpacity = ifelse(
ohio$locationname %in% rv$selected_counties, 1, 0.1
),
highlight = highlightOptions(
fillOpacity = 1,
bringToFront = TRUE)
)
Each of these pieces all fit into our observeEvent function for a click on the map, so in our consolidated code it will look like this:
Code
observeEvent(input$mapfilter_shape_click, {
click <- input$mapfilter_shape_click
if (click$id %in% rv$selected_counties) {
rv$selected_counties <-
rv$selected_counties[rv$selected_counties != click$id]
} else if(click$id == "selected"){
rv$selected_counties <-
rv$selected_counties[rv$selected_counties !=
tail(rv$selected_counties, n = 1)]
}else {
rv$selected_counties <- c(rv$selected_counties, click$id)
}
leafletProxy("mapfilter", session) |>
addPolygons(data = ohio,
layerId = ~locationname,
label = ~locationname,
fillColor = "steelblue",
col = "black",
weight = 2,
fillOpacity = ifelse(
ohio$locationname %in% rv$selected_counties, 1, 0.1
),
highlight = highlightOptions(
fillOpacity = 1,
bringToFront = TRUE)
)
})
Lastly, we have one more if else statement in our server. The following code chunk takes the reactive value rv$selected_counties and updates rv$filtered_data which we use to render the table. This logic will cause the data to reset when we have no selected counties (all the shapes are “unclicked”).
Code
observe({ # Update table filtering based on selected counties
if (!is.null(rv$selected_counties) &&
length(rv$selected_counties) > 0) {
# Check if any counties are selected
rv$filtered_data <- ohio |>
filter(locationname %in% rv$selected_counties)
} else {
rv$filtered_data <- ohio
}
})
Conclusion
This post was an excellent way for me to revisit my code and share an interesting and unique Shiny feature. In this process I ended up eliminating quite a few redundancies in my original code and reinforced some of the concepts of reactivity showcased here.
I hope you find this tutorial useful. If you put it to use, please share it with me! I would love to see the work you come up with.
See the full consolidated example code below.
Full Code
Code
library(leaflet)
library(shiny)
library(tigris)
library(CDCPLACES)
library(dplyr)
library(htmltools)
ohio <- get_places(state = "OH", measure = "ACCESS2", geometry = TRUE) |>
filter(datavaluetypeid == "AgeAdjPrv") |>
select(year, stateabbr, locationname, measure, data_value, geometry) |>
sf::st_transform(crs = 4326)
ui <- fluidPage(
tags$head(
tags$style(HTML(".leaflet-container { background: none; } .well { background: none;}"))
),
# Application title
titlePanel("My Demo App"),
# Sidebar with a slider input for number of bins
sidebarLayout(
sidebarPanel(
leafletOutput("mapfilter", height = 250)
),
# Show a plot of the generated distribution
mainPanel(
DT::DTOutput("table")
)
)
)
# Define server logic required to draw a histogram
server <- function(input, output, session) {
rv <- reactiveValues(selected_counties = NULL,
filtered_data = ohio) # Initialize reactive value for selected counties
observeEvent(input$mapfilter_shape_click, { # this is the logic behind the "click" of the map.
click <- input$mapfilter_shape_click
########## map behavior ################
# If a county is clicked
if (click$id %in% rv$selected_counties) {
# If selected, remove it
rv$selected_counties <- rv$selected_counties[rv$selected_counties != click$id]
} else if(click$id == "selected"){ # when a county is clicked again it is removed
rv$selected_counties <- rv$selected_counties[rv$selected_counties != tail(rv$selected_counties, n = 1)]
}else {
# If not selected, add it
rv$selected_counties <- c(rv$selected_counties, click$id)
}
leafletProxy("mapfilter", session) |>
addPolygons(data = ohio,
layerId = ~locationname,
label = ~locationname,
fillColor = "steelblue", # Change fill color based on selection
col = "black",
weight = 2,
fillOpacity = ifelse(ohio$locationname %in% rv$selected_counties, 1, 0.1),
highlight = highlightOptions(
fillOpacity = 1,
bringToFront = TRUE)
)
})
output$mapfilter <- renderLeaflet({ # rendering the filter map
leaflet(ohio,
options = leafletOptions( # initializing the map
zoomControl = FALSE,
dragging = FALSE,
minZoom = 6,
maxZoom = 6
)) %>%
addPolygons(layerId = ~locationname,
label = ~locationname,
# fillColor = "black",
col = "black",
fillColor = "steelblue",
weight = 2,
fillOpacity = .1,
highlight = highlightOptions(
fillOpacity = 1,
bringToFront = TRUE
))
})
output$table <- DT::renderDT({
rv$filtered_data |>
sf::st_set_geometry(NULL) |>
DT::datatable()
})
observe({ # Update table filtering based on selected counties
if (!is.null(rv$selected_counties) & length(rv$selected_counties) > 0) { # Check if any counties are selected
rv$filtered_data <- ohio |>
filter(locationname %in% rv$selected_counties)
} else {
rv$filtered_data <- ohio
}
})
}
# Run the application
shinyApp(ui = ui, server = server)
R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
