4

I'm making a diverging bar plot for some Likert data (along these lines). The client has requested a "mean response" for each group, treating Likert responses as consecutive integers ("Strongly disagree" = 1, "Disagree" = 2, etc.); these means are displayed on top of the bars, in the "neutral" area.

For transparency, I want to add the numeric value of each Likert response to the legend. I could add the number to the label (e.g., "Strongly agree (5)"), but I would prefer to put it on top of the color box (e.g., on top of the blue square for "Strongly agree").

Here's the code that produces the diverging bar plot:

library(dplyr)
library(ggplot2)
library(RColorBrewer)
# The data.
df = structure(list(group = structure(c(1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L),
                                  .Label = c("Group A", "Group B", "Group C"),
                                  class = "factor"),
                response = c(1L, 1L, 1L, 2L, 2L, 2L, 3L, 3L, 3L, 4L, 4L, 4L, 5L, 5L, 5L),
                n = c(4, 8, 25, 8, 25, 29, 29, 35, 28, 25, 22, 12, 34, 10, 6),
                mean.response = c(3.8, 3, 2.5, 3.8, 3, 2.5, 3.8, 3, 2.5, 3.8, 3, 2.5, 3.8, 3, 2.5),
                response.fill = c("#CA0020", "#CA0020", "#CA0020",
                                  "#F4A582", "#F4A582", "#F4A582",
                                  "#F7F7F7", "#F7F7F7", "#F7F7F7",
                                  "#92C5DE", "#92C5DE", "#92C5DE",
                                  "#0571B0", "#0571B0", "#0571B0"),
                n.to.plot = c(4, 8, 25, 8, 25, 29, 14.5, 17.5, 14, 25, 22, 12, 34, 10, 6)),
           class = c("grouped_df", "tbl_df", "tbl", "data.frame"),
           row.names = c(NA, -15L),
           groups = structure(list(group = structure(1:3, .Label = c("Group A", "Group B", "Group C"),
                                                     class = "factor"),
                                   .rows = list(c(1L, 4L, 7L, 10L, 13L),
                                                c(2L, 5L, 8L, 11L, 14L),
                                                c(3L, 6L, 9L, 12L, 15L))),
                              row.names = c(NA, -3L),
                              class = c("tbl_df", "tbl", "data.frame"),
                              .drop = TRUE))
# Groups, responses, and colors.
n.groups = 3
groups = paste("Group", LETTERS[1:n.groups])
likert.responses = c("Strongly disagree", "Disagree", "Neutral", "Agree", "Strongly agree")
pal = brewer.pal(length(likert.responses), "RdBu")
# Make the plot.
ggplot(data = df, aes(x = group, y = n.to.plot, fill = response.fill)) +
  # Start with the "agree" responses.
  geom_bar(data = df %>% filter(response >= 3),
           stat = "identity") +
  # Add the "disagree" responses going the opposite way.
  geom_bar(data = df %>%
             filter(response <= 3) %>%
             mutate(n.to.plot = n.to.plot * -1),
           stat = "identity") +
  # Add text labels with the mean response for each group.
  geom_text(data = df %>%
              dplyr::select(group, mean.response) %>%
              distinct(),
            aes(x = group, y = 0,
                label = format(mean.response, nsmall = 1),
                fill = NA)) +
  # Specify fill colors.
  scale_fill_identity("Response", breaks = pal, labels = likert.responses,
                      guide = "legend") +
  # Adjust axis labels.
  scale_x_discrete("") +
  scale_y_continuous("Number of responses") +
  # Swap x and y axes.
  coord_flip() +
  # Add the prompt text as the title.
  ggtitle("I like program XYZ.")

enter image description here

And here's my desired output:

enter image description here

Taking inspiration from this answer, I tried adding a label aesthetic to the fill legend, but that did nothing:

+ guides(fill = guide_legend(override.aes = list(label = "foo")))

I know I can customize the shape of the legend symbols, but the problem is that I want two things: a square with the color, and a black digit superimposed on the square.

Update: custom annotation

@M-- suggests using annotation_custom, as described here. To do that, I'll need to figure out where the color boxes in the legend are. That's where I'm stuck; I can find the grobs for these boxes, but I can't figure out how to put text on top of them.

Grob for one of the color boxes (after saving the plot above as g; with guidance from this answer):

gt = ggplot_gtable(ggplot_build(g))
gb = which(gt$layout$name == "guide-box")
box.grob = gt$grobs[[gb]]$grobs[[1]]$grobs[[3]]

box.grob$x and box.grob$y are both 0.5npc; I tried adding a label with geom_text_npc, but the label is right in the middle of the plot. Clearly, I haven't correctly identified the location of the color box (or I'm not translating it to plotting coordinates correctly).

library(ggpmisc)
g + geom_text_npc(aes(npcx = 0.5, npcy = 0.5, label = "foo"))

enter image description here

A. S. K.
  • 2,504
  • 13
  • 22
  • I realized we don't have `response.fill` and `pal` is not sufficient for `breaks`! It all comes back to providing a reproducible example. – M-- Dec 04 '19 at 18:14
  • This may help https://stackoverflow.com/questions/12409960/ggplot2-annotate-outside-of-plot – M-- Dec 04 '19 at 18:14
  • 2
    `response.fill` is part of `df`, and `pal` is defined immediately under `df`. I see now that I neglected to include the required libraries (`RColorBrewer` is crucial for `pal`); I've edited the post to add those. – A. S. K. Dec 04 '19 at 18:17

1 Answers1

2

Thinking a little out of the box here, you can avoid the custom_annotation and using your idea of adding the number to the label like this:

likert.responses = c("1   Strongly disagree", "2   Disagree", "3   Neutral", "4   Agree", "5   Strongly agree")

and playing with the left margin of the legend label element_text a little:

guides(
  fill = guide_legend(label.theme = element_text(margin = margin(l = -18, unit = 'pt')))
)

This achieves what you want and have the advantage of scaling well when the plot changes sizes as we are using pt units.

Complete reproducible solution:

library(dplyr)
#> 
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#> 
#>     filter, lag
#> The following objects are masked from 'package:base':
#> 
#>     intersect, setdiff, setequal, union
library(ggplot2)
library(RColorBrewer)
# The data.
df = structure(list(group = structure(c(1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L),
                                      .Label = c("Group A", "Group B", "Group C"),
                                      class = "factor"),
                    response = c(1L, 1L, 1L, 2L, 2L, 2L, 3L, 3L, 3L, 4L, 4L, 4L, 5L, 5L, 5L),
                    n = c(4, 8, 25, 8, 25, 29, 29, 35, 28, 25, 22, 12, 34, 10, 6),
                    mean.response = c(3.8, 3, 2.5, 3.8, 3, 2.5, 3.8, 3, 2.5, 3.8, 3, 2.5, 3.8, 3, 2.5),
                    response.fill = c("#CA0020", "#CA0020", "#CA0020",
                                      "#F4A582", "#F4A582", "#F4A582",
                                      "#F7F7F7", "#F7F7F7", "#F7F7F7",
                                      "#92C5DE", "#92C5DE", "#92C5DE",
                                      "#0571B0", "#0571B0", "#0571B0"),
                    n.to.plot = c(4, 8, 25, 8, 25, 29, 14.5, 17.5, 14, 25, 22, 12, 34, 10, 6)),
               class = c("grouped_df", "tbl_df", "tbl", "data.frame"),
               row.names = c(NA, -15L),
               groups = structure(list(group = structure(1:3, .Label = c("Group A", "Group B", "Group C"),
                                                         class = "factor"),
                                       .rows = list(c(1L, 4L, 7L, 10L, 13L),
                                                    c(2L, 5L, 8L, 11L, 14L),
                                                    c(3L, 6L, 9L, 12L, 15L))),
                                  row.names = c(NA, -3L),
                                  class = c("tbl_df", "tbl", "data.frame"),
                                  .drop = TRUE))
# Groups, responses, and colors.
n.groups = 3
groups = paste("Group", LETTERS[1:n.groups])
likert.responses = c("1   Strongly disagree", "2   Disagree", "3   Neutral", "4   Agree", "5   Strongly agree")
pal = brewer.pal(length(likert.responses), "RdBu")
# Make the plot.
ggplot(data = df, aes(x = group, y = n.to.plot, fill = response.fill)) +
  # Start with the "agree" responses.
  geom_bar(data = df %>% filter(response >= 3),
           stat = "identity") +
  # Add the "disagree" responses going the opposite way.
  geom_bar(data = df %>%
             filter(response <= 3) %>%
             mutate(n.to.plot = n.to.plot * -1),
           stat = "identity") +
  # Add text labels with the mean response for each group.
  geom_text(data = df %>%
              dplyr::select(group, mean.response) %>%
              distinct(),
            aes(x = group, y = 0,
                label = format(mean.response, nsmall = 1),
                fill = NA)) +
  # Specify fill colors.
  scale_fill_identity("Response", breaks = pal, labels = likert.responses,
                      guide = "legend") +
  # Adjust axis labels.
  scale_x_discrete("") +
  scale_y_continuous("Number of responses") +
  # Swap x and y axes.
  coord_flip() +
  # Add the prompt text as the title.
  ggtitle("I like program XYZ.") -> test
#> Warning: Ignoring unknown aesthetics: fill

test + guides(
  fill = guide_legend(label.theme = element_text(margin = margin(l = -18, unit = 'pt')))
)

Created on 2019-12-07 by the reprex package (v0.3.0)

MalditoBarbudo
  • 1,815
  • 12
  • 18
  • This looks terrific. At first I was worried that spaces after digits of different widths would make the labels no longer left-justified, but I can't see a difference at all. An elegant solution! – A. S. K. Dec 07 '19 at 21:39