Skip to content

Binned scales are silently incorrect in 4.0.0 #6615

@christopherkenny

Description

@christopherkenny

I found a problem with using ggplot2::binned_scale(). When using 5 bins, it now requires 6 colors. This broke the stable version of ggredist on CRAN. It appears that this was not caught by revdep checks. Most importantly, this means that, if you provide a character vector of colors (instead of a vctrs vector), you will get an incorrect plot but no errors, as the bins are off. When using vctrs, the stricter behavior fails loudly.

I expected five colors to map onto five bins. I don't see this breaking change in the NEWS or in existing issues. I believe it is a subtle error in the subsetting logic and not intended.

Here is the code to reproduce the bug:

library(ggplot2)
library(ggredist)
data(oregon)

scale_fill_538 <- function(...) {
  ggplot2::binned_scale(aesthetics = 'fill',
                        palette = function(x) ggredist$fivethirtyeight,
                        breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
                        limits = c(.25, .75),
                        oob = scales::squish,
                        guide = 'colourbar',
                        ...
  )
}

oregon |> 
  ggplot(aes(fill = ndv / (ndv + nrv))) +
  geom_sf() +
  scale_fill_538()
#> Error in `vec_slice()`:
#> ! Can't subset elements past the end.
#> ℹ Location 6 doesn't exist.
#> ℹ There are only 5 elements.

What's going on here?
scale_fill_538() is a binned scale, based on 538's old electoral maps. It has 5 colors stored in a vctrs class from palette:

structure(c("#FA5A50", "#FF998A", "#EAE3EB", "#A1A9ED", "#5768AC"
), class = c("palette", "vctrs_vctr"))

If we update the scale to simply repeat the first color, it now fills in everything as expected.

library(ggplot2)
library(ggredist)
data(oregon)

scale_fill_538 <- function(...) {
  ggplot2::binned_scale(aesthetics = 'fill',
                        palette = function(x) c(ggredist$fivethirtyeight[1], ggredist$fivethirtyeight),
                        breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
                        limits = c(.25, .75),
                        oob = scales::squish,
                        guide = 'colourbar',
                        ...
  )
}

oregon |> 
  ggplot(aes(fill = ndv / (ndv + nrv))) +
  geom_sf() +
  scale_fill_538()

Created on 2025-09-15 with reprex v2.1.1

Digging a little deeper: When we don't use a vctrs class for the colors, we see the following:

library(ggplot2)
library(ggredist)
data(oregon)

scale_fill_538 <- function(...) {
  ggplot2::binned_scale(aesthetics = 'fill',
                        palette = function(x) c("#FA5A50", "#FF998A", "#EAE3EB", "#A1A9ED", "#5768AC"
                        ),
                        breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
                        limits = c(.25, .75),
                        oob = scales::squish,
                        guide = 'colourbar',
                        ...
  )
}

oregon |> 
  ggplot(aes(fill = ndv / (ndv + nrv))) +
  geom_sf() +
  scale_fill_538()

Created on 2025-09-15 with reprex v2.1.1

ie, it produces an incorrect plot but doesn't error. It drops the first color silently, so that the 5 bins use 4 colors + white in the legend.

And again, we can correct the chart

library(ggplot2)
library(ggredist)
data(oregon)

scale_fill_538 <- function(...) {
  ggplot2::binned_scale(aesthetics = 'fill',
                        palette = function(x) c("#FA5A50", "#FA5A50", "#FF998A", "#EAE3EB", "#A1A9ED", "#5768AC"
                        ),
                        breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
                        limits = c(.25, .75),
                        oob = scales::squish,
                        guide = 'colourbar',
                        ...
  )
}

oregon |> 
  ggplot(aes(fill = ndv / (ndv + nrv))) +
  geom_sf() +
  scale_fill_538()

Created on 2025-09-15 with reprex v2.1.1

Finally, this is an important issue as it silently produces incorrect plots. Here is a manually binned version, using dplyr to do the bins and adding a manual class with the names.

library(ggplot2)
library(ggredist)
data(oregon)

oregon |> 
  dplyr::mutate(class = dplyr::case_when(
    ndv / (ndv + nrv) < 0.35 ~ "Strong R",
    ndv / (ndv + nrv) < 0.45 ~ "Lean R",
    ndv / (ndv + nrv) < 0.55 ~ "Tossup",
    ndv / (ndv + nrv) < 0.65 ~ "Lean D",
    TRUE ~ "Strong D"
  )) |>
  ggplot(aes(fill = class)) +
scale_fill_manual(
  values = c(
    "Strong R" = "#FA5A50",
    "Lean R" = "#FF998A",
    "Tossup" = "#EAE3EB",
    "Lean D" = "#A1A9ED",
    "Strong D" = "#5768AC"
  )
) +
  geom_sf()

Created on 2025-09-15 with reprex v2.1.1


I'm relying on data from ggredist because it allows me to directly link an example pkgdown generated under the old ggplot version with the expected map, here: https://alarm-redist.org/ggredist/reference/scale_538.html (Sorry, I know it's never optimal to introduce a second package into the mix while reporting issues, but I figure the reference is net helpful.)

Image

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions