12  Time zones: spatial data and mapping with sf

In this chapter we’ll learn how to obtain map data using rnaturalearth``**, manipulate spatial data using **sf** and plot it with **ggplot2`, and add a custom bar chart legend to a map of the world.

Packages required in this chapter:

12.1 Data

The IANA (Internet Assigned Numbers Authority) tz database contains data on the history of local time for different locations around the world (Internet Assigned Numbers Authority 2023). The website states that “each main entry in the database represents a timezone for a set of civil-time clocks that have all agreed since 1970.” Many websites use the data in the IANA tz database to operate.

The time zones data was used as a TidyTuesday dataset in March 2023, where the data wrangling code was adapted from code by Davis Vaughan. There are actually four data sets included but we’ll focus on the timezones data for now. We can load the data into R using the tt_load() function from the tidytuesdayR package (Hughes 2022b):

tuesdata <- tt_load("2023-03-28")
timezones <- tuesdata$timezones

The data contains information for 4 different variables on 337 time zones. The zone column contains the time zone name; the latitude and longitude columns contain coordinates for the time zones principal location e.g. biggest city; and the comments column contains comments from the original time zone definition file.

12.2 Exploratory work

Let’s start by looking at what the timezones data looks like!

12.2.1 Data exploration

Let’s inspect the first few rows of the data using head():

head(timezones)
# A tibble: 6 × 4
  zone              latitude longitude comments      
  <chr>                <dbl>     <dbl> <chr>         
1 Africa/Abidjan        5.32     -4.03 <NA>          
2 Africa/Algiers       36.8       3.05 <NA>          
3 Africa/Bissau        11.8     -15.6  <NA>          
4 Africa/Cairo         30.0      31.2  <NA>          
5 Africa/Casablanca    33.6      -7.58 <NA>          
6 Africa/Ceuta         35.9      -5.32 Ceuta, Melilla

The first thing that jumps out is the comments column which appears to have a lot of missing data. Let’s plot it to confirm, making use of the is.na() function to plot only the counts of missing and non-missing data:

barplot(table(is.na(timezones$comments)))
Figure 12.1: A bar chart of the number of missing values in the comments column, where TRUE represents missing.

Almost 40% of the time zones have no comments associated with them. Inspecting the data further tells us that these comments typically offer clarification or an alternative for the timezone name. Although we could explore how the presence of these comments varies across the globe, we’ll use select() from dplyr to drop the comments column for now since it’s not the most interesting thing to plot in the data.

The first column, zone, contains information on the timezone name - it’s typically in the form of "Continent/City". To explore the spread of time zones across continents, we need to split this variable into two (or extract just the continent name from the timezone name). We can use separate_wider_delim() from tidyr to create two new columns from the zone column, splitting on the / character. Some of the timezone names have two / in their name e.g. "America/North_Dakota/New_Salem" meaning there are too many pieces for two columns. We can tell separate_wider_delim() to merge the last pieces since it still uniquely specifies the location.

timezones_data <- timezones |>
  select(-comments) |>
  separate_wider_delim(
    cols = zone,
    delim = "/",
    names = c("continent", "place"),
    too_many = "merge"
  )

Now, we can explore how many time zones there are per continent, again using the barplot() function. In Figure 12.2, we can see that the first part of the time zone names don’t quite map to continents since there are nine values - but they do map onto large geographic regions.

barplot(table(timezones_data$continent))
Figure 12.2: A bar chart of the number of defined time zones in each of nine geographic regions.

Of course, when we have coordinate data, the most obvious thing to do is plot those coordinates on a map!

12.2.2 Exploratory sketches

Given that we have a latitude and longitude data for the principal locations (usually largest cities) within each time zone, the first map idea that springs to mind is a world map with the time zone locations plotted as points:

Figure 12.3: Initial sketch of time zone locations as points on a world map.

We could color the points based on the geographic region (continent) they belong to. As we’ve already seen, if we do this in ggplot2, coloring based on a variable automatically adds a legend to the chart. Instead of the traditional legend using colored squares next to the category label, we could add our own custom legend - using a bar chart. The bars will show the number of time zones per region, and be colored in the same way as the points on the map. This bar chart legend below the map doesn’t take up any more space than a traditional legend, but it does add information (or at least makes the existing information quicker and easier to process).

Figure 12.4: Initial sketch of points on a world map with text in the lower left corner, and a bar chart as a legend in the bottom right.

A title and subtitle can be added below the map, next to the bar chart. Positioning the text in a more square layout (rather than a long string of text across the top of the chart) makes it easier to read, and helps to prevent the visualization from becoming very wide and short.

12.3 Preparing a plot

To make our plot, we need to get some background map data and understand how to work with multiple spatial data files at once.

12.3.1 Maps with rnaturalearth

Before we start plotting points on a map, we need a map of the world that we can use as a background to show the underlying countries. In Chapter 11, we used map_data from ggplot2. Here, let’s look at an alternative using the rnaturalearth package (Massicotte and South 2023).

The rnaturalearth package allows you to interact with Natural Earth map data. You can download polygons for different geographic regions using the ne_countries() function. The default is to download data for all countries:

world <- ne_countries()
Tip 12.1: Polygons for a specific country with rnaturalearth

If you wanted only the polygon(s) for a specific country or region, you can specify the country argument:

uk <- ne_countries(
  country = "united kingdom"
)

For specified countries, the ne_states() function provides administrative level 1 polygons e.g. major within-country regions such as states.

The default output from the ne_countries() function is an sf object - where sf stands for Simple Features. Simple Features is a standardized model for representing geometric objects such as points, lines, and polygons in spatial databases and geographic information systems (GIS). The sf package (Pebesma 2018) implements simple features in R, allowing simple features to be represented as a data.frame (or tibble).

Since sf objects in R can be thought of as fancy data.frames, this means they can be plotted using ggplot2. In fact, ggplot2 has built-in capabilities for plotting sf objects - through the geom_sf() function. That means that we can build maps from sf objects, in the same we we build other types of charts: by starting with the ggplot() function, and then layering on the geom_sf() geometry.

bg_map <- ggplot() +
  geom_sf(data = world)
bg_map
Figure 12.5: Map of the world showing country outlines, with the default gray background.

You’ll notice that there’s one key difference - there’s no aesthetic mapping using the aes() function. Since the spatial coordinates are embedded within the sf object, there’s no need to explicitly map the x and y variables. The geom_sf() function can directly extract and use the embedded coordinates for plotting.

12.3.2 Spatial objects with sf

Though you can combine geom_sf() with other functions and non-sf data, such as geom_point(), it’s often easier to convert the other data to sf objects first. The main reason for this is to ensure the coordinate reference system (CRS) is the same for both geometries. Coordinate reference systems define how the Earth’s three-dimensional surface is represented, either in a three-dimensional coordinate system (e.g. latitude and longitude) or as a two-dimensional projected map. There are many different coordinate reference systems, each commonly used for different areas of the world. If you’re combining multiple spatial objects, they may have different coordinate reference systems. For example, using the British National Grid (BNG) CRS, London has the following coordinates: X = 492983 and Y = 188837. Under the World Geodetic System 1984 (WGS84) CRS, London has the following coordinates: Longitude (X) = 1.200235W and Latitude (Y) = 53.870659N. You can imagine how these two coordinates cannot be plotted on the same map without transforming them first.

In R, you can use the sf package to set or transform between different coordinate reference systems. The easiest way is by using EPSG codes - numerical identifiers assigned to specific coordinate reference systems. The WGS84 uses EPSG code 4326. This is the coordinate reference system used in the world data that we’re using for the background map. You can check using the st_crs() function from sf which retrieves the CRS from an object:

st_crs(world)
Coordinate Reference System:
  User input: WGS 84 
  wkt:
GEOGCRS["WGS 84",
    DATUM["World Geodetic System 1984",
        ELLIPSOID["WGS 84",6378137,298.257223563,
            LENGTHUNIT["metre",1]]],
    PRIMEM["Greenwich",0,
        ANGLEUNIT["degree",0.0174532925199433]],
    CS[ellipsoidal,2],
        AXIS["latitude",north,
            ORDER[1],
            ANGLEUNIT["degree",0.0174532925199433]],
        AXIS["longitude",east,
            ORDER[2],
            ANGLEUNIT["degree",0.0174532925199433]],
    ID["EPSG",4326]]

To make sure the CRS of the timezones data matches the CRS of the world data, we can set the CRS of the timezones data as 4326. At the moment, the timezones data is not a spatial object - it’s just a simple data.frame that contains two columns with latitude and longitude information. First, we need to convert it into an sf object using the st_as_sf() function from sf, also specifying the column names that relate to the coordinates data.

The latitude and longitude coordinates given in the timezones data are in the EPSG:4326 CRS, so we don’t need to convert the CRS - just set it. We know it’s in EPSG:4326 because this information is given to us with the data, and we can set it using the crs argument of st_as_sf().

timezones_sf <- timezones_data |>
  st_as_sf(
    coords = c("longitude", "latitude"),
    crs = 4326
  )
Tip 12.2: Dealing with an unknown CRS

If you don’t know which CRS your coordinates are in, the best thing to do is go back to the source of the data to see if you can find that information. Otherwise, you may wish to try the guess_crs() function from crsuggest (Walker 2022) which will guess potential coordinate reference systems for data that are lacking a defined CRS.

You can then use the st_set_crs() function to set the coordinate reference system. .

12.3.3 The first plot

Since the time zones coordinates are now stored as an sf object, we can plot it in the same way as the world data. We pass in the timezones_sf object to the data argument, and specify that we want to color the points based on the continent column by passing this into the color argument inside the aes() function:

basic_map <- bg_map +
  geom_sf(
    data = timezones_sf,
    mapping = aes(color = continent)
  )
basic_map
Figure 12.6: Points representing different time zone locations on a world map, with points colored by continent and a legend indicating color mapping on the right of the map.

We now have a basic map with points colored by region, and the default legend added on the right hand side. Let’s get started on a better, custom legend.

Before we create a new legend for the map, we need to define which colors will be used in the legend (and the rest of the plot).

12.3.4 Colors

As we’ve done in previous chapters, we start by defining a text color, highlight color, and background color as variables.

text_col <- "#2F4F4F"
highlight_col <- "#508080"
bg_col <- "#F0F5F5"

Then we define a named vector of colors, mapping the names of the regions to different hex codes. It can be difficult to find a qualitative color palette with enough colors (one for each of the nine regions) that remains colorblind safe. Paul Tol discusses several options for qualitative palettes in his Colour schemes and templates blog post (Tol 2021). Here, we use the muted qualitative color scheme palette (Tol 2021) which has 10 colors (including a pale gray for missing data) and is colorblind safe:

col_palette <- c(
  "#CC6677", "#332288", "#DDCC77",
  "#117733", "#88CCEE", "#882255",
  "#44AA99", "#999933", "#AA4499"
)
names(col_palette) <- unique(timezones_sf$continent)

12.3.5 Fonts

Similarly, we also load any typefaces we want to use. Again, for this visualization we’re using Google Fonts, so we can make use of the font_add_google() function in sysfonts. For the title, we’ll use Fraunces, an old style serif typeface inspired by those used in the 20th century. For the body text, we’ll use Commissioner, a sans serif typeface. As we’ve done in previous chapters, we use showtext_auto() to use showtext automatically for plotting fonts, and set the desired resolution using showtext_opts() to set the dpi.

font_add_google(name = "Commissioner")
font_add_google(name = "Fraunces")
showtext_auto()
showtext_opts(dpi = 300)
body_font <- "Commissioner"
title_font <- "Fraunces"

12.3.6 Creating a custom bar chart legend

There are many ways to make a bar chart in ggplot2 - the two most often used are geom_bar() and geom_col(). What’s the difference? Well, geom_bar() is essentially a special version of geom_col() that counts up how many observations are in each category for you. For this visualization, we’re going to use the category counts for other purposes besides just defining the height of the bars so we’ll do the counting ourselves and use geom_col() instead.

We’ll use the count() function from dplyr to count how many observations of each geographical region are present in the continent column. We could use either the timezones_sf data or the timezones_data here as the input data. If you try both, you’ll notice that, as we expect, the count column (n) is the same in both cases, but that the two outputs are not identical! When you run count(timezones_data, continent), you get a 2 column data.frame. When you run count(timezones_sf, continent) you get an 3 column data.frame that remains an sf object. This is because the geometry column in sf can be described as sticky - you often (though not always) want the sf class to be preserved after any operations. You can remove it using the st_drop_geometry() function from sf:

bar_data <- timezones_sf |>
  count(continent) |>
  st_drop_geometry()

Let’s plot a basic bar chart, by starting (as we always do) with passing our new bar_data object into the ggplot() function. We also add the aesthetic mapping via the aes() function and place the continent on the x-axis and the count (n) on the y-axis. We then draw the columns of the bar chart using geom_col():

ggplot(
  data = bar_data,
  mapping = aes(x = continent, y = n)
) +
  geom_col()
Figure 12.7: Bar chart showing the number of time zone locations in each continent.

To make it work effectively as a legend, we need to do two things:

  • Color the bars based on the continent column.
  • Add labels directly to the bars instead, and remove the x-axis labels and legend.
Tip 12.3: Adding color to bar charts

If you were just creating a normal bar chart (not to be used as a legend) then adding colors to the bars might make your plot look brighter but it doesn’t add any additional information. The categories can already be distinguished by the labels on the x-axis.

Let’s remake our bar chart, this time also mapping fill and label to the continent column in the aesthetic mapping. Adding label to the aesthetic mapping in ggplot() won’t affect our bar chart in any way since we aren’t (yet) adding geom_text() but there’s no harm in adding it as a global aesthetic mapping here anyway. We’ll also use scale_fill_manual() to set the colors used to those defined in the col_palette vector we created earlier.

bar_plot <-
  ggplot(
    data = bar_data,
    mapping = aes(
      x = continent, y = n,
      fill = continent, label = continent
    )
  ) +
  geom_col() +
  scale_fill_manual(
    values = col_palette
  )
bar_plot
Figure 12.8: Bar chart showing the number of time zone locations in each geographical region, with different colors for each region.

Now we can add the text labels directly to the bars. But where should they go? It’s quite common to add category labels in line with the end of the bar but that might not work well here. If we add labels within the bars, for regions with few time zones where the bars are short, the text will be very squashed. If we add labels outside the bars, for regions with many time zones where the bars are long, the text will run off the graph or extend the height of the visualization. Neither is ideal. But maybe we could have the best of both worlds. We want to add labels under the following conditions:

When the bars are long, text should:

  • appear inside the bar and be right-aligned;
  • be light in color to contrast the dark bar backgrounds;

And conversely, when the bars are short, text should:

  • appear outside the bar and be left-aligned;
  • be dark in color to contrast the light plot background;

It might take a little bit of trial and error to find the value that defines a bar as being short or tall. Here, we’ll use 45. If a region has more than 45 time zones, it’s classed as a tall bar. Otherwise, it’s short.

We want to map the alignment and color of the text to a (transformation of) the n column in the data set. This sounds like something that should go into an aesthetic mapping in the aes() function. We already know the color argument can be used to map the text color. The hjust and vjust arguments are used for horizontal and vertical positioning of text. Unfortunately, within the geom_text() function, neither hjust nor vjust can be used inside the aes() function.

Luckily, the ggtext package once again comes to the rescue! We can use the geom_textbox() function from ggtext instead of geom_text(). It works very similarly to geom_text() but it allows us to map variables to hjust and vjust.

We’ll use case_when() from dplyr to specify the settings for the hjust argument, depending on the value of n. When n is greater than 45, hjust should be 1 to use right alignment, otherwise it should be 0 to use left alignment. If you wanted to, you could create a new column in the data instead of using case_when() directly inside the aes() function.

Since geom_textbox() the draws a box, we need to control both (i) the alignment of the box relative to the coordinates using hjust, and (ii) the alignment of the text within the box using halign. Within geom_textbox() we also use orientation = "left-rotated" to rotate the text anti-clockwise by 90 degrees (similar to using angle = 90 in geom_text()). We can remove the background fill color and box outline by setting both fill = NA and box.color = NA, and set the size and font family options using the size and family arguments (where we pass in our body_font variable we defined earlier).

Tip 12.4: Alignment of text

In ggplot2, hjust (and halign) controls horizontal alignment and vjust (and valign) controls vertical alignment. When text is rotated, the non-rotated alignment arguments should be used. For example, although we’re moving the text up and down, we still use hjust to align the text.

We use a similar process for setting the color of the text - using case_when() to specify if the text should use the bg_col or text_col color based on whether or not it is greater than 45. Note that we wrap the bg_col and text_col variables inside the I() function to use these variables as is rather than treating them as a variable to map to.

Finally, we can also use theme_void() to remove all existing theme elements, and theme(legend.position = "none") to remove the legend.

legend_plot <- bar_plot +
  geom_textbox(
    mapping = aes(
      hjust = case_when(
        n > 45 ~ 1,
        TRUE ~ 0
      ),
      halign = case_when(
        n > 45 ~ 1,
        TRUE ~ 0
      ),
      color = case_when(
        n > 45 ~ I(bg_col),
        TRUE ~ I(text_col)
      )
    ),
    family = body_font,
    size = 2.5,
    fill = NA,
    box.color = NA,
    orientation = "left-rotated"
  ) +
  theme_void() +
  theme(
    legend.position = "none"
  )
legend_plot
Figure 12.9: A minimalist bar chart showing the number of time zone locations in each continent, with labels on each bar naming the continent.

Now we have a much nicer looking custom legend. We can apply some nicer styling to our main map before we join the two together!

12.4 Advanced styling

We have multiple elements of our plots that we need to improve:

  • the background map should use our defined colors rather than the defaults;
  • the points should also use our defined colors;
  • a title and subtitle should be added using our defined typefaces;

12.4.1 Applying colors

Let’s start by re-drawing the background map but using our text_col for the border color and a semi-transparent version of our highlight_col for the fill color. We can use the alpha() function from ggplot2 to set the transparency to 30% (0.3).

We’ll also re-draw the points again with a small adjustment - let’s change the shape that’s used for the points. You can set the shape using the pch (or shape) argument. There are 25 different options available for pch, which can be specified using the numbers 1 to 25. The shape we’ll use here is a circle with a dot in the middle. Unfortunately, this isn’t one of the 25 available options so we’ll have to make it ourselves. We can draw the dot in the middle using the default shape but making it a little smaller. We can add the circle by choosing pch = 21 (which allows you to control both the fill and color of the shape) and making it a little bit bigger with a transparent fill.

basic_map <- ggplot() +
  # Apply colors to background map
  geom_sf(
    data = world,
    color = text_col,
    fill = alpha(highlight_col, 0.3)
  ) +
  # Draw points for time zone locations
  geom_sf(
    data = timezones_sf,
    mapping = aes(color = continent),
    size = 0.4,
  ) +
  # Draw outer circle
  geom_sf(
    data = timezones_sf,
    mapping = aes(color = continent),
    size = 1.6,
    pch = 21,
    fill = "transparent"
  )
basic_map
Figure 12.10: Points representing different time zone locations on a world map, with points colored by geographical region. The default legend is shown on the right hand side.

We also apply the same colors for (both of) the points as we did for the bar chart (remembering to use scale_color_manual() rather than scale_fill_manual() for points).

col_map <- basic_map +
  scale_color_manual(values = col_palette)

12.4.2 Editing the axes

Before we add the title and subtitle text, we need to make some space for it (and the legend that we’ll add later). Let’s set the limits of the x- and y- axes using scale_x_continuous() and scale_y_continuous(). We extend the lower limit of the y-axis beyond the range of the data, leaving blank space at the bottom where we can overlay the text. We also adjust the breaks in the x-axis scale to make the grid lines closer together. The choice of grid lines every 15 x values might seem like an odd choice, but every fifteen degrees difference in longitude (x-axis) is approximately one hour of time difference (since Earth rotates 360 degrees in 24 hours, or 15 degrees per hour)!

It will take some (probably a lot of) trial and error to figure out exactly how much you need to extend the y-axis by. Here, we want the height of the blank space to be about half the height of the map area. Using st_bbox(world) to return the bounding box of the world map data, you’ll see that the y-axis of the world map ranges between -90 to +83.6. This means extending the y-axis by around 80 or 90 is a good starting point. We also remove the extra padding around the sides by setting expand = FALSE inside coord_sf().

axes_map <- col_map +
  scale_x_continuous(
    breaks = seq(-180, 180, by = 15),
    limits = c(-190, 190)
  ) +
  scale_y_continuous(
    limits = c(-170, 100)
  ) +
  coord_sf(expand = FALSE)
axes_map
Figure 12.11: Points representing different time zone locations on a world map, with points colored by geographical region. The default legend is shown on the right hand side, with a large blank space below the world map.

12.4.3 Adding text

We can create our custom Font Awesome icon caption, as described in Chapter 7, which we’ll later add to the top of the visualization:

social <- social_caption(
  icon_color = highlight_col,
  font_color = text_col,
  font_family = body_font
)

As we did in Chapter 6 and Chapter 7, we can also add colored text within the subtitle to denote the categories. This might be unnecessary for this visualization since we have our custom bar chart legend, but reinforcing the color mapping won’t hurt. Here, we’ll use ggtext as we did in Chapter 7 (although you can also use marquee as we did in Chapter 6 if you prefer). This means we’ll be writing HTML <span></span> tags and using glue() to pass in the colors from col_palette vector. Here, we’ll take a slightly different approach of calling the variables - using each vector element name instead of the index. This approach is a little more manual, but can be useful if you want the written text to be slightly different to the category name. The code is also often a little bit clearer to read! Rather than using our source_caption() function as we’ve done in previous chapters, we’ll add information about the source directly in the subtitle text.

subtitle <- glue("Time zones tend to follow the boundaries between countries and their subdivisions instead of strictly following longitude. For every one-hour time, a point on the earth moves through 15 degrees of longitude. Each point relates to one of 337 time zones listed in the IANA time zone database. The colors show which time zones are in
<span style='color:{col_palette[\"Africa\"]};'>Africa </span>,
<span style='color:{col_palette[\"America\"]};'>America </span>,
<span style='color:{col_palette[\"Antarctica\"]};'>Antarctica </span>,
<span style='color:{col_palette[\"Asia\"]};'>Asia </span>,
<span style='color:{col_palette[\"Atlantic\"]};'>Atlantic </span>,
<span style='color:{col_palette[\"Australia\"]};'>Australia </span>,
<span style='color:{col_palette[\"Europe\"]};'>Europe </span>,
<span style='color:{col_palette[\"Indian\"]};'>Indian </span>, and
<span style='color:{col_palette[\"Pacific\"]};'>Pacific </span> zones.<br>**Data**: IANA tz database<br>")

We also specify some text for the visualization title. We’re going to join together the title and subtitle and plot them as one text object, so we also use HTML <span></span> tags to set the font size, family, and color of the title.

title <- glue("<span style='font-size:12pt; font-family:{title_font}; color:{text_col};'>Time Zones of the World</span><br>")
title_text <- glue("{title}{subtitle}")

We add the social caption to the map using the tag option in the labs() function - just as we did in Chapter 11. When we edit the theme elements in the final step, we’ll specify a position for the tag.

Unfortunately, we can’t have two tags and since we also want a non-standard position for the title/subtitle text object, we’ll use geom_textbox() to add it instead. We specify the x- and y- coordinates of where we want the textbox to go (again, lots of trial and error!) and pass the social object in for the label. We also use the other arguments in geom_textbox() to set the font size and family as well as specify the alignment of the text and box (just as we did earlier when making the bar chart). The box fill and outline colors are removed by setting their arguments to NA.

text_map <- axes_map +
  # add social icons
  geom_textbox(
    data = data.frame(x = 0, y = 93, label = social),
    mapping = aes(x = x, y = y, label = label),
    family = body_font,
    size = 2.3,
    fill = NA,
    box.color = NA,
    halign = 0.5,
    hjust = 0.5,
    valign = 0
  ) +
  # add title and subtitle
  labs(tag = title_text)

12.4.4 Adjusting themes

The final step is making a few small changes to the ggplot2 theme. We start by removing all theme elements using theme_void() and setting the base_family and base_size for the text. Although we don’t have many text elements in our visualization controlled by the theme() functions, this will still affect the tag text.

Using the theme() function, we make some further edit to remove the default legend, change the background color to out bg_col variable, and add the grid lines back in with an almost transparent text_col color. The position of the tag text can be set using the plot.tag.position argument to place it in the bottom left of the plot. We also use element_textbox_simple() from ggtext to format the text in the tag since it includes the HTML <span></span> tags. Within element_textbox_simple(), the maxwidth argument is used to set the width of the textbox, ensuring the only takes up the left hand side of the blank space at the bottom. The bar chart legend will go on the other side.

styled_map <- text_map +
  theme_void(base_size = 6, base_family = body_font) +
  theme(
    legend.position = "none",
    plot.background = element_rect(
      fill = bg_col,
      color = bg_col,
    ),
    panel.grid.major = element_line(
      color = alpha(text_col, 0.1)
    ),
    # add and position text
    plot.tag.position = c(-0.01, 0.12),
    plot.tag = element_textbox_simple(
      color = text_col,
      hjust = 0,
      maxwidth = unit(200, "pt"),
      margin = margin(
        l = 15, t = 5, b = 10
      )
    )
  )
styled_map
Figure 12.12: Points representing different time zone locations on a world map, with points colored by geographical region. Title and subtitle are shown in the bottom left.

12.4.5 Joining plots with patchwork

Finally, we need to join the legend bar chart to our main plot. For this, we’ll use patchwork (Pedersen 2024). The patchwork package allows you to combine multiple visualizations (including ggplot2 graphics, base R plots, or gt tables) into a single layout. It’s very flexible, and you can add arrange plots easily into simple rows or columns, or create very complex layouts with many nested plots. The patchwork package also allows you to add inset plots i.e. a smaller plot placed within a larger plot to provide additional detail.

Tip 12.5: Arranging plots with patchwork

Although this book doesn’t contain examples of arranging multiple plots side-by-side, patchwork makes it very easy to do so. If you have two plots g1 and g2, then you can arrange them side-by-side by simply running g1 + g2. More complex layouts can be achieved by specifying a grid of which plots should be placed where. The patchwork package also contains additional functions for adding multiplot annotations or titles, consistent themes across all subplots, and defining plot alignment across multiple pages. Read the package documentation at patchwork.data-imaginist.com for examples.

In this visualization, rather than the more traditional approach of arranging plots side by side, we instead want to position one plot (bar chart) on top of another (main map). We start with our styled_map and add the legend_plot on top using the inset_element() function. The four positions given in the inset_element() function are the left, bottom, right, and top outer bounds of the inset plot. The default unit is npc (normalized parent coordinates). In this setting the bottom left is (0, 0) and the top right is (1, 1). This means specifying the left outer bound as 0.55 tells patchwork to start at the left hand side of the inset plot 55% of the way in from the left of the main plot.

We also remove the added margin from around the edge of the plot using the theme() function and setting all of the margins to zero. Remember that when combining a theme() with a patchwork object, it’s added using the & operator rather than +.

styled_map + inset_element(legend_plot, 0.55, 0, 1, 0.3) &
  theme(plot.margin = margin(0, 0, 0, 0))
Figure 12.13: Points representing different time zone locations on a world map, with points colored by geographical region. Title and subtitle are shown in the bottom left, with an inset bar chart shown on the bottom right being used as a legend.

Now our map is finished and we can save it with ggsave()!

ggsave(
  filename = "time-zones.png",
  width = 5,
  height = 0.92 * 5
)

12.5 Reflection

What could still be improved about this plot?

Although the bar chart legend is an improvement on a boring, traditional legend, it could do with some further improvement. For example, it’s not immediately clear what the bar chart shows. With a little bit of time, readers probably put together the number of dots with the height of the bars but it could be more obvious. Adding numbers to the bar chart would also improve readability. Perhaps this could be with a traditional y-axis, or perhaps as more informative labels. For example, instead of a label that reads "Africa", it could read "Africa (18 time zones)".

If we’re talking about improving readability of bar charts, an easy action is to make the bars (and therefore text) horizontal - it’s much easier to read text that’s the right way up!

Each plot created during the process of developing the original version of this visualization was captured using camcorder, and is shown in the gif below. If you’d like to learn more about how camcorder can be used in the data visualization process, see Section 14.1.

12.6 Exercises

  • Edit the bar chart legend to include information about the number of time zones in each geographic region.

  • Edit the bar chart to have horizontal bars instead of vertical ones.

  • Consider where the updated bar chart legend is positioned. Is the lower right corner still a good location if the bars are horizontal? Try moving it somewhere else.