3

The problem

I have some code that creates a map with numerous points, annotated with some stats, on a monthly basis. This worked fine until I updated ggplot2 to 3.3.6, after which the plots have broken and I've not been able to figure out the solution. As far as I can tell, the issue lies in the ggsave() call, but I don't know what has changed and how to fix it.

There are two main issues that have come up. But to illustrate, I will attach below comparison images of the "good" and "bad" versions (click links to see full-size).

Good points

image

Bad points

image

The good version has uniformly coloured points/squares while the latter has strange points coloured irregularly.

Good text

image

Bad text

image

The non-breaking space is formatted properly in the good version, while it appears as a box in the bad version.

Debugging attempts

One potential cause I noted for the irregular points was some updates in the works for the "size" parameter (see this blog post). Such things have happened in the past as well (see this for example). However, this update is claimed to be for the next release, and moreover like I said, I have a hunch the issue I'm facing has something to do with ggsave(). And regardless, I already tried tweaking the size and stroke of the geom_point() but haven't been able to recover the old version properly.

The RStudio plot device doesn't indicate any issue with the points, and using the png() method instead of ggsave() to write produces the correct/"good" version.

png(filename = "map_cov_plain.png",
units = "in", width = 8, height = 11, bg = "transparent", res = 300)
print(map_cov_plain)
dev.off()

I tried reverting to ggplot 3.3.5 but this did not fix the issue. Moreover, two others tried the same code on their separate systems, both with ggplot 3.3.6, but only one replicated my issue while the other produced the good version. Nevertheless, the code was certainly working fine until July, only after which I updated several packages and the code broke.

For the record, I have ensured that the issue is not with the data. So, although I have used data until June to illustrate the good version, that same dataset generates the bad maps when the code is run now (i.e., after the updates).

I am hoping someone with a better understanding of the package and the update will be able to figure out what exactly the breaking change was!

Other links

Reprex

There are two files required for the reprex below to work:

  • .RData file with the necessary data objects (link)
  • logo image required in one of the maps (link)
library(lubridate)
library(tidyverse)
library(glue)

library(magick)
library(scales) # for comma format of numbers
library(grid)


# loading objects
load("reprex.RData")


map_cov_logo <- image_convert(image_read("bcilogo-framed.png"), matte = T)

map_cov_text <- glue::glue("{label_comma()(data_cov$LOCATIONS)} locations
                      {label_comma()(data_cov$LISTS)} lists
                      {label_comma()(data_cov$HOURS)} hours
                      {label_comma()(data_cov$PEOPLE)} people
                      
                      {label_comma()(data_cov$STATES)} states/UTs
                      {label_comma()(data_cov$DISTRICTS)} districts
                      
                      {label_comma()(data_cov$SPECIES)} species
                      {round(data_cov$OBSERVATIONS, 1)} million observations")

map_cov_footer <- glue::glue("Data until September 2022")


### map with annotations of stats and BCI logo ###
map_cov_annot <- ggplot() +
  geom_polygon(data = indiamap, aes(x = long, y = lat, group = group), 
               colour = NA, fill = "black")+
  geom_point(data = data_loc, aes(x = LONGITUDE, y = LATITUDE), 
             colour = "#fcfa53", size = 0.05, stroke = 0) +
  # scale_x_continuous(expand = c(0,0)) +
  # scale_y_continuous(expand = c(0,0)) +
  theme_bw() +
  theme(axis.line = element_blank(),
        axis.text.x = element_blank(),
        axis.text.y = element_blank(),
        axis.ticks = element_blank(),
        axis.title.x = element_blank(),
        axis.title.y = element_blank(),
        panel.grid.major = element_blank(),
        panel.grid.minor = element_blank(),
        panel.border = element_blank(),
        # panel.border = element_blank(),
        plot.background = element_rect(fill = "black", colour = NA),
        panel.background = element_rect(fill = "black", colour = NA),
        plot.title = element_text(hjust = 0.5)) +
  coord_cartesian(clip = "off") +
  theme(plot.margin = unit(c(2,2,0,23), "lines")) +
  annotation_raster(map_cov_logo, 
                    ymin = 4.5, ymax = 6.5,
                    xmin = 46.5, xmax = 53.1) +
  annotation_custom(textGrob(label = map_cov_text,
                             hjust = 0,
                             gp = gpar(col = "#FCFA53", cex = 1.5)),
                    ymin = 19, ymax = 31,
                    xmin = 40, xmax = 53)  +
  annotation_custom(textGrob(label = map_cov_footer,
                             hjust = 0,
                             gp = gpar(col = "#D2D5DA", cex = 1.0)),
                    ymin = 15, ymax = 16,
                    xmin = 40, xmax = 53) 

ggsave(map_cov_annot, file = "map_cov_annot.png", device = "png",
       units = "in", width = 13, height = 9, bg = "transparent", dpi = 300)



### plain map without annotations ###
map_cov_plain <- ggplot() +
  geom_polygon(data = indiamap, aes(x = long, y = lat, group = group), 
               colour = NA, fill = "black")+
  geom_point(data = data_loc, aes(x = LONGITUDE, y = LATITUDE), 
             colour = "#fcfa53", size = 0.05, stroke = 0.1) +
  # scale_x_continuous(expand = c(0,0)) +
  # scale_y_continuous(expand = c(0,0)) +
  theme_bw() +
  theme(axis.line = element_blank(),
        axis.text.x = element_blank(),
        axis.text.y = element_blank(),
        axis.ticks = element_blank(),
        axis.title.x = element_blank(),
        axis.title.y = element_blank(),
        panel.grid.major = element_blank(),
        panel.grid.minor = element_blank(),
        panel.border = element_blank(),
        plot.margin = unit(c(0, 0, 0, 0), "cm"),
        # panel.border = element_blank(),
        plot.background = element_rect(fill = "black", colour = NA),
        panel.background = element_rect(fill = "black", colour = NA),
        plot.title = element_text(hjust = 0.5)) +
  coord_map()

ggsave(map_cov_plain, file = "map_cov_plain.png", device = "png",
       units = "in", width = 8, height = 11, bg = "transparent", dpi = 300)

  • 2
    Any better luck swapping in `device = ragg::agg_png` (assuming you have `ragg` installed)? – Jon Spring Oct 21 '22 at 03:53
  • @JonSpring Nope, produces the same faulty figures. This must be because `ggsave()` itself uses ragg by default and it is ragg that has changed in 3.3.6. See Thomas Lin Pederson's [response](https://github.com/tidyverse/ggplot2/issues/5016#issuecomment-1293206047). – Karthik Thrikkadeeri Nov 03 '22 at 05:50
  • 1
    A solution, as described in the GitHub issue linked above, is to use `ggsave(device = png)`, but beware that this is different from `ggsave(device = "png")`. The former uses the default `png()` device, which does not do anti-aliasing, whereas I assume the latter defaults to `ragg::agg_png()` which automatically does anti-aliasing. – Karthik Thrikkadeeri Nov 03 '22 at 14:25

1 Answers1

2

I think this comes down to taste, and you prefer the result of the rendering without anti-aliasing. It looks bolder and sharper because the cells that are partially activated are shown at full brightness, in contrast with fully black adjacent pixels. Mssr. Pederson argues that the anti-aliased version is in some respects a truer depiction of the underlying data, since it will perceptually give the dots more consistent weight and spacing that corresponds to their actual size and placement.

Two examples:

set.seed(42)
library(ggplot2)
df_rand <- data.frame(x = runif(1000), y = runif(1000))

ggplot(df_rand, aes(x,y)) +
  geom_point(colour = "#fcfa53", size = 0.01, stroke = 0) +
  theme_void() +
  theme(plot.background = element_rect(fill = "black", colour = NA),
        panel.background = element_rect(fill = "black", colour = NA))

ggsave("rand_no_anti-alias.png", device = png,
       units = "px", width = 100, height = 100)

ggsave("rand_ragg_anti-alias.png", device = ragg::agg_png(),
       units = "px", width = 100, height = 100)

enter image description here

enter image description here

Of these two, you prefer the former for your use case and particular settings.

df_grid <- data.frame(expand.grid(x = 1:30, y = 1:30))

ggplot(df_grid, aes(x,y)) +
  geom_point(colour = "#fcfa53", size = 0.01, stroke = 0) +
  theme_void() +
  theme(plot.background = element_rect(fill = "black", colour = NA),
        panel.background = element_rect(fill = "black", colour = NA))

ggsave("grid_no_anti-alias.png", device = png,
       units = "px", width = 100, height = 100)

ggsave("grid_ragg_anti-alias.png", device = ragg::agg_png(),
       units = "px", width = 100, height = 100)

enter image description here

enter image description here

Note here that the version without anti-aliasing creates phantom groupings out of a uniform grid. Its algorithm shows full intensity even when the point covers a tiny part of the pixel. The anti-aliased version reflects that the points cover a very small part of each pixel, and it attempts to even out the error by sometimes depicting a point in one pixel and sometimes using two adjacent pixels, but dimmer. While the uniform data creates some moire effect in this contrived example, in my view the perceptual distortion is smaller.

If I make the point size much larger (e.g. 0.3), it makes for a closer match in brightness with the non-anti-aliased version. This version avoids the phantom groupings that the non-anti-aliased version has, but at the cost of the pixels looking smudged at the pixel level. That's anti-aliasing for ya.

enter image description here

Technical arguments aside, use the rendering method that gives you the output you want.

Jon Spring
  • 55,165
  • 4
  • 35
  • 53
  • Thanks for this demonstration! I understand now why the anti-aliasing gives a truer depiction of weight and spacing of the points. (1) I hadn't realised that the `png()` method shows an entire pixel coloured despite the actual point only covering a fraction of the pixel. How do we know what fraction of the pixel a point covers, and how does it change with the `size = ` argument? (2) Could you elaborate on how sometimes using one and sometimes two pixels helps even out the error? (What error exactly?) (3) Did you mean the anti-aliased version produces a moire effect but less distortion? – Karthik Thrikkadeeri Nov 04 '22 at 03:21
  • (4) Does the phantom grouping, or just distortion, happen even when the points are not in a uniform grid? I assume it's at least perceptible only with strict uniform grids like in your example? – Karthik Thrikkadeeri Nov 04 '22 at 03:23