Search code examples
rggplot2plotlyggplotly

How can I reduce the horizontal legend item/symbol width/padding/margin in Plotly?


I have a horizontal legend in a Plotly plot, however I'd like to reduce the amount of spacing between each symbol/label to preserve space. I've tried a number of options both in ggplot (including legend.spacing.x, and legend.key.width, but these aren't preserved when I convert the plot to a Plotly) and in Plotly (including the itemmargin, spacing, tracegroupgap layout settings - these only seem to work for vertical legends).

Here's an image where I've tried to highlight the space I'm trying to remove

And my code to reproduce that plot:

p <- ggplot(mtcars, aes(x = mpg, y = hp, color = as.factor(cyl))) +
  scale_color_discrete(name = '') +
  geom_point()

ggplotly(p) %>% 
  layout(legend = list(orientation = "h", y = 1.15, x = 0))

Any other ideas? Thanks!


Solution

  • UPDATE: Based on Commented Use of Shiny

    To add a dependency in Shiny, you need to both declare the dependency in the ui and modify the dependency in the plot.

    BTW: In this update, I combined methods 1 and 2 from the original answer. (All of the original content is still in this answer, just below the update.)

    There is a call for htmlDependency(). That is added to the ui. The function fixDep is unchanged from my original answer.

    library(htmltools)  # only for htmlDependency()
    library(tidyverse)
    library(plotly)
    library(shiny)
    
    # update the Plotly dependency (UI and plot)
    
    # this is for the UI
    newDep <- htmlDependency(name = "plotly-latest",
                             version = "2.21.1", 
                             src = list(href = "https://cdn.plot.ly"),
                             script = "plotly-2.21.0.min.js")
    
    # this is for the plot (same as in original answer)
    fixDep <- function(plt) {
      # changes to dependency so that entrywidth/entrywidthmode work
      plt$dependencies[[5]]$src$file = NULL
      plt$dependencies[[5]]$src$href = "https://cdn.plot.ly"
      plt$dependencies[[5]]$script = "plotly-2.21.0.min.js"
      plt$dependencies[[5]]$local = FALSE
      plt$dependencies[[5]]$package = NULL
      plt
    }
    
    

    I added your plot two times here. That is to show you the differences whether you include the legend mods or not.

    Please note that you have to change the dependency on both plots for this to work. (Even if you don't use the benefits of the updated dependency on both plots.)

    I used fluidPage, but that's not required, you can probably use any ui formatter that works with shiny.

                             # your original plot (unchanged)
    p <- ggplot(mtcars, aes(x = mpg, y = hp, color = as.factor(cyl))) +
      scale_color_discrete(name = '') +
      geom_point()
    
    ui <- fluidPage(
      createWebDependency(newDep),         # adding the dep to the UI
      ### the rest of your ui
      
      plotlyOutput("fixLegendSpacing"),    # added for reproducibility
      plotlyOutput("originalLegend")       # added for comparison
    )
    
    server <- function(input, output, session) {
      output$fixLegendSpacing <- renderPlotly({
        # your call for ggplotly, with JS for legend entry sizing
        ggplotly(p) %>% layout(legend = list(orientation = "h", y = 1.15, x = 0,
                                             entrywidth = 5)) %>%   # <<---- I added this!
          fixDep() %>% 
          htmlwidgets::onRender(
          "function(el, x) {
            what = el.querySelectorAll('text.legendtext');  /* Find all legend entries */
            gimme = what[1].x.baseVal[0].value;             /* Collect current px spacing */
            what.forEach(function(entry) {
              entry.setAttribute('x', gimme * .7)           /* Modify whitespace w/in entry */
            });
          }")
      })
      output$originalLegend <- renderPlotly({
        # your original call for ggplotly
        ggplotly(p) %>% layout(legend = list(orientation = "h", y = 1.15, x = 0)) %>% 
          fixDep()                     # <<------ you still have to change the dep here!
      })
    }
    shinyApp(ui, server)
    

    Here are the plots as in this app.

    enter image description here



    Original Answer

    The Plotly JS dependency that is used in the R Plotly package does not include the specific arguments designed for this type of modification, so it won't be an 'out-of-the-box' Plotly solution.

    Here are two approaches you can use to control this spacing.

    1. Use JS to modify the plot
    2. Update the Plotly dependency

    NOTE The Viewer pane will not reflect the change, you'll have to open it in your browser to see it.

    To open a plot in your browser from RStudio's viewer pane, click on the rightmost icon in the menubar. (It's next to the broom.)

    enter image description here

    Here are the three legends aligned horizontally, original plot, method 1 (JS), and method 2 (updated dependency). You can see that the JS only changed within each entry, while the dependency update changed the overall size (without changing the font size).

    enter image description here

    Method 1: Use JS to modify the plot

    This uses the package htmlwidgets, but as it's one call, I've just appended the library name to the function.

    This modifies the SVG using Javascript. I've used .7 or 70%. Depending on what you're looking for this is the value that will increase or decrease the white space. I've added comments in the JS to explain a bit about what's happening.

    library(tidyverse)
    library(plotly)
    
    # your plot as you coded it
    p <- ggplot(mtcars, aes(x = mpg, y = hp, color = as.factor(cyl))) +
      scale_color_discrete(name = '') +
      geom_point()
    
    # your call for ggplotly, with JS for legend entry sizing
    ggplotly(p) %>% layout(legend = list(orientation = "h", y = 1.15, x = 0)) %>% 
      htmlwidgets::onRender(
        "function(el, x) {
          what = el.querySelectorAll('text.legendtext');  /* Find all legend entries */
          gimme = what[1].x.baseVal[0].value;             /* Collect current px spacing */
          what.forEach(function(entry) {
            entry.setAttribute('x', gimme * .7)           /* Modify whitespace w/in entry */
          });
        }")
    
    

    Using Method 1 (using JS): enter image description here

    Method 2: Update the Plotly dependency

    This modifies the space for each entry, so the icon (dot, line, etc.) and the string that goes with it. This may not be particularly useful here.

    The relative arguments are (along with Plotly's descriptions):

    entrywidth
    Code: fig.update_layout(legend_entrywidth=)
    Type: number greater than or equal to 0
    Sets the width (in px or fraction) of the legend. Use 0 to size the entry based on the text width, when entrywidthmode is set to "pixels".

    entrywidthmode
    Code: fig.update_layout(legend_entrywidthmode=)
    Type: enumerated , one of ( "fraction" | "pixels" )
    Default: "pixels"
    Determines what entrywidth means.

    The coding I use here won't work if you're using Shiny. (If you are, let me know I can walk you through how to do this with Shiny, as well.)

    Here is a function that will modify the Plotly dependency in your plot.

    fixDep <- function(plt) {
      # changes to dependency so that entrywidth/entrywidthmode work
      plt$dependencies[[5]]$src$file = NULL
      plt$dependencies[[5]]$src$href = "https://cdn.plot.ly"
      plt$dependencies[[5]]$script = "plotly-2.21.0.min.js"
      plt$dependencies[[5]]$local = FALSE
      plt$dependencies[[5]]$package = NULL
      plt
    }
    
    All the code altogether
    library(tidyverse)
    library(plotly)
    
    
    fixDep <- function(plt) {
      # changes to dependency so that entrywidth/entrywidthmode work
      plt$dependencies[[5]]$src$file = NULL
      plt$dependencies[[5]]$src$href = "https://cdn.plot.ly"
      plt$dependencies[[5]]$script = "plotly-2.16.1.min.js"
      plt$dependencies[[5]]$local = FALSE
      plt$dependencies[[5]]$package = NULL
      plt
    }
    
    
    # your plot as you coded it
    p <- ggplot(mtcars, aes(x = mpg, y = hp, color = as.factor(cyl))) +
      scale_color_discrete(name = '') +
      geom_point()
    
    # your call for ggplotly, with additional calls for legend entry sizing
    ggplotly(p) %>% 
      layout(legend = list(orientation = "h", y = 1.15, x = 0,
             entrywidth = 15)) %>% # <--- I added, using default entrywidthmode
      fixDep()
    

    Using Method 2 (dependency): enter image description here



    If you have any questions, let me know.