Show leaflet spinner when rendering slow leaflet map in shiny

Hi

I have a shiny app with a leaflet map that is slow to render. I would like to show a spinner while the map is rendering.

This has been asked last year but didn't receive an answer.
https://community.rstudio.com/t/how-to-add-progress-bar-data-loading-icon-to-leaflet-map-in-rmarkdown-document-with-shiny/22623

I have tried the solution suggested here but the spinner doesn't persist while the leaflet is rendering.
https://stackoverflow.com/questions/51774255/displaying-a-loading-spinner-on-a-map-when-updating-with-leafletproxy-in-shiny

Apparently this is a javascript solution but it's beyond me to understand. Does anyone know if this can be implemented in shiny?
https://stackoverflow.com/questions/31430375/leaflet-show-spin-while-map-is-rendered

Have you seen the waiter package? It might be a good option for you.

1 Like

I think that is equivalent to the approach I've already tried, and is also in the shinybusy package. The problem here is a bit different since it is during the actual rendering of a widget that is already loaded.

One way you can do this is by wrapping the leafletOutput in a container and position a second element over the map output to simulate a loading screen.

# example leaflet output container
tags$div(class = "map-container"
    tags$div(class = "map-loading", tags$p("Loading..."),
    leafletOutput("map")
)

With a few custom javascript handlers, you can show and hide the loading message by modifying the display properties (although, I would recommend using add/remove css class for accessibility purposes; but this would depend on the aim of the app). Iā€™m using flex in this example to position the message in the center of the box.

I created a basic loading message and wrapped the js handler in show_loading and hide_loading. Both functions take an argument elem which is the id of the loading element. This allows you to reuse this function for more than one loading message. I called the function at the start of the render leaflet function and removed at the end. There's also a brief pause to simulate a longer process.

I'd be happy to answer any additional questions. Hope that helps!

# pkgs
library(shiny)
library(leaflet)

# show handler
show_loading <- function(elem) {
    session <- shiny::getDefaultReactiveDomain()
    session$sendCustomMessage("show_loading", elem)
}

# hide handler
hide_loading <- function(elem) {
    session <- shiny::getDefaultReactiveDomain()
    session$sendCustomMessage("hide_loading", elem)
}

# ui
ui <- tagList(
    tags$head(
        tags$style(
            ".map-container {
          height: 100%;
          width: 100%;
          position: relative;
        }",
            ".map-loading {
          position: absolute;
          display: flex;
          justify-content: center;
          align-items: center;
          width: 100%;
          height: 400px;
          background-color: #bdbdbd;
          text-align: center;
        }"
        )
    ),
    tags$main(
        tags$h2("Map Output"),
        tags$div(
            class = "map-container",
            tags$div(
                id = "leafletBusy",
                class = "map-loading",
                tags$p("Loading...")
            ),
            leafletOutput("map")
        )
    ),
    tags$script(
        "
    Shiny.addCustomMessageHandler('hide_loading', function(value) {
      const el = document.getElementById(value);
      el.style.display = 'none';
    });
    Shiny.addCustomMessageHandler('show_loading', function(value) {
      const el = document.getElementById(value);
      el.style.display = 'flex';
    });
    "
    )
)

# server
server <- function(input, output, session) {

    # render map on button click
    output$map <- renderLeaflet({
        
        # simulate building
        show_loading(elem = "leafletBusy")

        # build map
        m <- leaflet() %>%
            addTiles() %>%
            setView(-93.65, 42.0285, zoom = 17) %>%
            addPopups(-93.65, 42.0285, "Here is the <b>Department of Statistics</b>, ISU")


        # hide loading elem and return map
        Sys.sleep(5)
        hide_loading(elem = "leafletBusy")
        return(m)
    })
}

# app
shinyApp(ui = ui, server = server)

EDITED

Fixed issue where leaflet map was not returned. Map was assigned to an object m, and then returned after a brief pause and when the loading screen is removed

Awesome I'll have a look.

The reprex doesn't work for me, there is no button, and I just get a grey rectangle, no map.

In my app, I use leafletProxy to update the map, how would I apply the loading message during leafletProxy ?

Hi @woodward,

Sorry about that issues. I updated the example and created an RStudio.cloud example: https://rstudio.cloud/project/1076906.

The issue was that map was not returning when other functions are run inside renderLeaflet. I assigned it to an object and then returned after the loading screen was removed. I also moved the Sys.sleep. It occurs after the leaflet map is built.

In the UI, the click the button... text was leftover from previous tests. I removed it from the example.

Here are the changes.

# render map on button click
    output$map <- renderLeaflet({

        # simulate building
        show_loading(elem = "leafletBusy")
-      Sys.sleep(5)

        # build map
-       leaflet() %>%
+        m <- leaflet() %>%
            addTiles() %>%
            setView(-93.65, 42.0285, zoom = 17) %>%
            addPopups(-93.65, 42.0285, "Here is the <b>Department of Statistics</b>, ISU")


        # hide loading elem and return map
+        Sys.sleep(5)
        hide_loading(elem = "leafletBusy")
+       return(m)
    })

I haven't tested using this with leafletProxy, but I would imagine using the loading screens might work in a similar manner as the renderLeaflet.

observe({
     show_loading(elem = "leafletBusy")

     # do something with map here
     m <- leafletProxy(....) %>% ...

    # hide busy
    hide_loading(elem = "leafletBusy")
    return(m)
})

Hope that helps!

Hey @woodward,

I updated the RStudio cloud project. The file app.R contains all of the code in this thread. In the file app2.R, I rewrote the loading UI as a functional component and separated the css and js (see www). The demo introduces loading dots rather than a basic loading message.

1 Like

I added the following code to your app.R. You can see that the loading message works when loading the map, but when adding the markers it does not work. This is my problem. By the way I am using a custom spinner for the loading. Please don't spent too much time on this, I do not consider it vital to my project.

# in ui
 actionButton("plotbutton", label = "Add Markers"),

# in server
  observeEvent(input$plotbutton, {
    show_loading(elem = "leafletBusy")
    dlat <- 1/111000*100 # degrees per metre
    leafletProxy("map") %>% 
      addMarkers(lng = -93.65+(runif(5000)*2-1)*dlat*3, 
                 lat = 42.0285+(runif(5000)*2-1)*dlat)
    hide_loading(elem = "leafletBusy")
  })

Adjusting the z-index of the loading element should do the trick. Leaflet outputs have a z-index value that is greater than the custom loading ui. This would explain why the showing/hiding runs without any errors.

You can add the z-index property inline:

# inline
tags$div(
    id = "leafletBusy",
   class = "map-loading",
   style = "z-index: 9999;"
    ...
)

Or in an external css file:

.map-loading {
     ... 
+    z-index: 9999;
}

You can use any value you like. I'm not sure what the default leaflet z-index value is. I defaulted to a higher number in case there there are different values for sub-elements.

Using z-index in my css file did make my spinner appear.
And I can use showElement() and hideElement() to toggle it on and off. And delay() to delay this. However the behaviour isn't very consistent or nice, and it disappears well before the entire leaflet render is finished (I don't know in advance how long the render will take (1-10 seconds)).

But z-index doesn't seem to work for your app.R.

I agree, this isn't the best approach. The original thinking was to provide more user-level control over when to hide and show an element, but it is interfering with the rendering of the leaflet function. I'm thinking it is better to attach these functions via htmlwidgets using onRender function rather than the previous approach. This might be a better method as you do not have to explicitly hide and show the loading element.

Using the onRender function, it is possible to use the leaflet events whenReady and .on("layeradd",...). I created a new event listener that runs when input$plotbutton is clicked. Inside, I wrote a Promise that shows the loading screen at the start, and then hides it when the layeradd event is finished. Add the onRender function in the renderLeafet. You do not need to call it anywhere else.

# within renderLeaflet
leaflet() %>%
    addTiles() %>%
    setView(-93.65, 42.0285, zoom = 17) %>%
    addPopups(
         lng = -93.65,
         lat = 42.0285,
         popup = "Here is the <b>Department of Statistics</b>, ISU"
    ) %>%
    onRender(., "function(el, x, data){
        // do something here
    }")

The javascript code looks like this.

// defined in r onRender(., "...")
// requires: el, x, data 
function(el, x, data) {

    // select map and busy ui
    var m = this;
    const elem = document.getElementById('leafletBusy');

    // when map is rendered, display loading
    // adjust delay as needed (time is in milliseconds)
    m.whenReady(function () {
        elem.style.display = 'flex';
        setTimeout(function () {
            elem.style.display = 'none';
        }, 3000)
    });

    // set on click event
    const b = document.getElementById('plotbutton');
    plotbutton.addEventListener('click', function (event) {

        // show loading element
        elem.style.display = 'flex';
        (new Promise(function (resolve, reject) {

            // leaflet event: layeradd
            m.addEventListener('layeradd', function (event) {
                console.log(event.type)
                // resolve after a few seconds to ensure all
                // elements rendered (adjust as needed)
                // time is in milliseconds
                setTimeout(function () {
                    resolve('done');
                }, 500)
            })
        })).then(function (response) {
            // resolve: hide loading screen
            console.log('done');
            elem.style.display = 'none';
        }).catch(function (error) {
            // throw errors
            console.error(error)
        })
    });
}

It might be easier to wrap the onRender js using an R function so you can pass other element ids, adjust delays, display properties, etc. In the cloud project, I created a new file leaflet-on-render-app.R. I'm also copying the full code here as I'm not seeing any of the saved changes from earlier edits (there's something odd with the project).

# pkgs
library(shiny)
library(leaflet)
library(htmlwidgets)

# ui
ui <- tagList(
    tags$head(
        tags$style(
            ".map-container {
          height: 100%;
          width: 100%;
          position: relative;
        }",
            ".map-loading {
          position: absolute;
          display: none;
          justify-content: center;
          align-items: center;
          top: 0;
          left: 0;
          width: 100%;
          height: 400px;
          background-color: #bdbdbd;
          text-align: center;
          z-index: 9999;
        }"
        )
    ),
    tags$main(
        tags$h2("Map Output"),
        actionButton("plotbutton", label = "Add Markers"),
        tags$div(
            class = "map-container",
            tags$div(
                id = "leafletBusy",
                class = "map-loading",
                tags$p("Loading...")
            ),
            leafletOutput("map")
        )
    )
)

# server
server <- function(input, output, session) {
    output$map <- renderLeaflet({
        leaflet() %>%
            addTiles() %>%
            setView(-93.65, 42.0285, zoom = 17) %>%
            addPopups(
                lng = -93.65,
                lat = 42.0285,
                popup = "Here is the <b>Department of Statistics</b>, ISU"
            ) %>%
            onRender(., "
                function(el, x, data) {
                    // select map and busy ui
                    var m = this;
                    const elem = document.getElementById('leafletBusy');

                    // when map is rendered, display loading
                    // adjust delay as needed
                    m.whenReady(function() {
                           elem.style.display = 'flex';
                            setTimeout(function() {
                           elem.style.display = 'none';
                        }, 3000)
                    });

                    const b = document.getElementById('plotbutton');
                    plotbutton.addEventListener('click', function(event) {

                        // show loading element
                        elem.style.display = 'flex';
                        (new Promise(function(resolve, reject) {

                            // leaflet event: layeradd
                            m.addEventListener('layeradd', function(event) {
                                console.log(event.type)
                                // resolve after a some time to ensure all
                                // elements rendered (adjust as needed)
                                // time is in milliseconds
                                setTimeout(function() {
                                    resolve('done');
                                }, 500)
                            })
                        })).then(function(response) {

                            // resolve: hide loading screen
                            console.log('done');
                            elem.style.display = 'none';

                        }).catch(function(error) {

                            // throw errors
                            console.error(error)
                        })
                    });

                }")
    })

    # add points on render
    observeEvent(input$plotbutton, {
        dlat <- 1 / 111000 * 100 # degrees per metre
        n <- 5000
        leafletProxy("map") %>%
            addMarkers(
                lng = -93.65 + (runif(n) * 2 - 1) * dlat * 3,
                lat = 42.0285 + (runif(n) * 2 - 1) * dlat
            )
    })
}

# app
shinyApp(ui = ui, server = server)

This works almost.

On my browser the "Loading..." message is in a grey box which takes up the whole screen and pushes the leaflet down while rendering. Seems it's not positioned correctly?

The plotbutton event is also hard coded into the JS. In my app there are multiple ways the leafletProy gets updated with new features, it is not tied to a single event. How would I handle this?

Thanks so much for your help!

It's a bit hard to tell where the issue is. Does this occur in the example app (posted above) or in your app? What browser are you using? Depending on the browser and if you are running the example application locally, we may need to add vendor prefixes to get flex to work. However, since the previous examples have worked (positioning wise), perhaps there is something missing in the markup. Is the leaflet output nested inside the loading UI markup?

This is bit tricky. It would depend on the other click events. Can you rewrite the other click events as functions? If so, I think you can call them from within the leaflet on render script.

const b = document.getElementById('plotbutton');
b.addEventListener('click', function(event) {
    // existing code here
    ....

    // other functions to run
    ...
}

I doubt if it would work the other way (i.e., assigning the leaflet function to a variable). I think we might be approaching the limitations of manipulating rendered htmlwidgets from shiny. I'm not sure if there are any other packages or extensions that could help with this. The last resort might be to rewrite the map in pure JS and pass the data to the leaflet function using custom message handlers. It might not be ideal, but at least you could integrate non-leaflet js with more ease.

NOTE There is a typo in the onRender script. The event should associated with the button b not plotbutton. Weirdly, the event would still run without an undefined error. These are uncertain times...

const b = document.getElementById('plotbutton'); 
- plotbutton.addEventListener('click', function(event) {
+ b.addEventListener('click', function(event){

Yes it occurs in the example app as well. I am using Chrome.
The previous examples also had this grey box, but I didn't realise it was a mistake until it appeared while rendering the markers on the leaflet. Maybe it is a flex issue.

In my app the leaflet update calls are triggered through various reactive code in the server, they are not directly attached to button clicks. For example the user clicks a radio button, the server calculates a new data set and updates the leaflet.

There are a few packages that provide waiting messages or gifs in shiny but I think none of them work during widget updating which is what I need.

I am unable to reproduce this issue.

I think I understand now. The gray background was part of the original example to completely hide the leaflet map during rendering. It isn't necessary and can be removed from the css. In the selector .map-loading, remove the background-color property.

.map-loading {
    position: absolute;
    display: none;
    justify-content: center;
    align-items: center;
    top: 0;
    left: 0;
    width: 100%;
    height: 400px;
-    background-color: #bdbdbd;
    text-align: center;
    z-index: 9999;
}

If you have the flexibility, a WebGL-based visualization library might be a better option. These libraries can handle large datasets and it would eliminate the need for loading screens. The mapdeck package is very good.

Hi D

I made some progress with this yesterday using the automatic method on this stackoverflow issue (which my predecessor discovered). The javascript is not working correctly though. I posted a reprex as an answer to the SO issue.