Rotating polygons in sf

R
sf
map projections
ggplot2
Author
Published

March 31, 2023

The east coast of the United States spans nearly 20 degrees of longitude and 30 degrees of latitude, with a major axis that runs more “northeast-to-southwest” than “north-to-south”. Because of this, I wind up with a lot of wasted space when trying to plot the coast. Take a look to see what I mean:

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(sf)
Linking to GEOS 3.11.2, GDAL 3.6.2, PROJ 9.2.0; sf_use_s2() is TRUE
library(rnaturalearth)
Support for Spatial objects (`sp`) will be deprecated in {rnaturalearth} and will be removed in a future release of the package. Please use `sf` objects with {rnaturalearth}. For example: `ne_download(returnclass = 'sf')`
library(ggplot2)

theme_set(theme_minimal())
east_coast <- 
  # Import the Natural Earth states polygon using rnaturalearth
  ne_states(country = 'united states of america',
            returnclass = 'sf') |>
  # crop to the US east coast
  st_crop(xmin = -82, xmax = -67,
          ymin = 20, ymax = 48)
The rnaturalearthhires package needs to be installed.
Installing the rnaturalearthhires package.
Downloading GitHub repo ropensci/rnaturalearthhires@HEAD

── R CMD build ─────────────────────────────────────────────────────────────────
* checking for file 'C:\Users\darpa2\AppData\Local\Temp\RtmpmmpUkt\remotesc40603f1bc5\ropensci-rnaturalearthhires-db8e433/DESCRIPTION' ... OK
* preparing 'rnaturalearthhires':
* checking DESCRIPTION meta-information ... OK
* checking for LF line-endings in source and make files and shell scripts
* checking for empty or unneeded directories
* building 'rnaturalearthhires_1.0.0.tar.gz'
Installing package into 'C:/Users/darpa2/AppData/Local/R/win-library/4.3'
(as 'lib' is unspecified)
Warning: attribute variables are assumed to be spatially constant throughout
all geometries
ggplot() +
  geom_sf(data = east_coast)

Firstly, I maybe should choose a different spatial polygon – this one has sunk the entirety of Virginia’s eastern shore! I’ll ignore that for now and move on.

If I’m planning to map migrations of fish along the coast (a common occurrence), I only need a little bit along the coast. The Sargasso Sea to the bottom right and Great Lakes to the top left are completely unnecessary.

One way to address this is to rotate the map so that the axis running from Florida to Maine is more-or-less vertical. My first attempt at this was using an affine transformation as outlined in the sf package documentation, but that didn’t quite work out.

Affine transformation

According to Wikipedia:

an affine transformation…is a geometric transformation that preserves lines and parallelism, but not necessarily Euclidean distances and angles.

In addition to the sf vignette linked above, the free Geocomputation with R book provides a good outline. I’ll use their rotating function below to rotate the polygon counterclockwise by 40 degrees, which should make that N-S axis.

# Rotation function
rotation <- function(a){
  r <- a * pi / 180 # degrees to radians
  matrix(c(cos(r), sin(r), -sin(r), cos(r)), nrow = 2, ncol = 2)
} 

# Apply affine transformation by rotating the geometry column
ec_affine <- east_coast |> 
  mutate(geometry = geometry * rotation(-40))

ggplot() +
  geom_sf(data = ec_affine)

Alright! Now all we need to do is trim the plotting area down and we’re in business!

But… wait a minute. Those axes make no sense. Since when is Maine at -10 degrees latitude? Let’s compare how the coordinates were changed.

# Original polygon
east_coast$geometry[1]
Geometry set for 1 feature 
Geometry type: POLYGON
Dimension:     XY
Bounding box:  xmin: -82 ymin: 38.87425 xmax: -80.52023 ymax: 41.98446
Geodetic CRS:  WGS 84
POLYGON ((-82 39.02017, -81.98432 39.01206, -81...
# Affine-transformed polygon
ec_affine$geometry[1]
Geometry set for 1 feature 
Geometry type: POLYGON
Dimension:     XY
Bounding box:  xmin: -89.49897 ymin: -22.87228 xmax: -87.42692 ymax: -19.59544
CRS:           NA
POLYGON ((-87.89733 -22.8174, -87.8801 -22.8135...

It looks like the raw values were changed (as anticipated). However, they’re values that don’t really make sense in terms of the coordinate reference system with which we started. There is also no longer a CRS listed! So, our polygon is “spatial” insofar as it having a spatial attributes via a well-known text geometry column as made by sf, but it’s no longer actually something that we know how to place in space.

This is where the problems begin.

What if, for instance, we wanted to add a layer from a different source?

nc <- st_read(system.file("shape/nc.shp", package="sf"),
              quiet = T)

ggplot() +
  geom_sf(data = ec_affine) +
  geom_sf(data = nc, fill = 'red')
Error in st_transform.sfc(X[[i]], ...): cannot transform sfc object with missing crs

We get a blank plot and an error – since the affine-transformed polygon no longer has a CRS, sf and ggplot no longer know where or how to place the new object onto the map.

Oblique Mercator

A flurry of failed Googling eventually led me to this Stack Overflow answer, where the author suggests using the oblique Mercator projection.

The difference between the Mercator (what we see when we look at something like Google Maps…ish; that’s actually web Mercator), transverse Mercator (the “TM” of “UTM” fame), and oblique Mercator lies in what it considers to be the major axis. A Mercator uses the equator, while a transverse Mercator uses a meridian (a particular longitude). Oblique Mercator uses some arbitrary line. This line can be determined using a point (the +lonc and +lat_0 arguments in a PROJ string) and an azimuth (either the +alpha or +gamma) or two points (+lat_1, +lon_1, +lat_2, +lon_2). I’ve found that providing a rotation away from North in degrees to the +gamma argument is what makes the most-intuitive sense to me.

ec_omerc <- east_coast |> 
  st_transform('+proj=omerc +lat_0=40 +lonc=-74 +gamma=-40')

ggplot() +
  geom_sf(data = ec_omerc)

This seems to be doing what we want at first blush. The graticule is rotated in a way that makes sense, and the axes are consistent with what we would think the proper values would be. What does it look like under the hood?

ec_omerc$geometry[1]
Geometry set for 1 feature 
Geometry type: POLYGON
Dimension:     XY
Bounding box:  xmin: -639662.3 ymin: -514225.3 xmax: -440446.8 ymax: -162784.6
Projected CRS: +proj=omerc +lat_0=40 +lonc=-74 +gamma=-40
POLYGON ((-480816 -505539.5, -479177.8 -505499,...

Seems that units are in something like meters and it has a CRS! This means we could add a different spatial polygon:

ggplot() +
  geom_sf(data = ec_omerc) +
  geom_sf(data = nc, fill = 'red')

Awesome! Now to trim the map to our specification with coord_sf.

ggplot() +
  geom_sf(data = ec_omerc) +
  geom_sf(data = nc, fill = 'red') +
  coord_sf(xlim = c(-77, -69), ylim = c(37, 44))

Well, that…didn’t work. Why not? Well, in essence, we’re providing two different units: ec_omerc has units of meters and we’ve provided units of degrees to coord_sf, assuming that it’s still using latitude and longitude (a CRS of WGS83/EPSG4326 in this case).

The hint to solve this is in the help documentation of coord_sf, specifically in the default_crs argument:

The default CRS to be used for non-sf layers (which don’t carry any CRS information) and scale limits. The default value of NULL means that the setting for crs is used. This implies that all non-sf layers and scale limits are assumed to be specified in projected coordinates. A useful alternative setting is default_crs = sf::st_crs(4326), which means x and y positions are interpreted as longitude and latitude, respectively, in the World Geodetic System 1984 (WGS84).

When we use the xlim and ylim arguments, it’s going to default to using the CRS that is in the first layer. This would be our oblique Mercator projection, so we would have to provide something in meters. It’s so easy to just look at the axes that have been printed and work from there – so, we’re thinking in WGS84/EPSG 4326. We can just tell coord_sf that this is our frame of reference using the default_crs argument.

ggplot() +
  geom_sf(data = ec_omerc) +
  geom_sf(data = nc, fill = 'red') +
  coord_sf(default_crs = 4326,
           xlim = c(-77, -69), ylim = c(37, 44))

Almost there. But, wait – shouldn’t North Carolina be in the picture if we’re down around 37 degrees latitude? Let’s check using the same limits in the original projection.

ggplot() +
  geom_sf(data = east_coast) +
  coord_sf(xlim = c(-77, -71), ylim = c(37, 44))

Yes – North Carolina is there. So what’s going on?

Well, we are seeing 37 degrees latitude in the oblique Mercator projection – it’s the little sliver on the bottom right. If we’re rotating everything counter-clockwise by 40 degrees and keeping the same rectangular view, this means that we’re adding some latitude at the bottom right of the frame and removing some from the top left. Similarly, we’d add some longitude in the bottom left and remove some from the top right. We need to fudge things now to get what we want included.

ggplot() +
  geom_sf(data = ec_omerc) +
  geom_sf(data = nc, fill = 'red') +
  coord_sf(default_crs = 4326,
           xlim = c(-75, -73), ylim = c(34, 44.5))

There we go! So much less wasted space!

Summary

In closing, here are the main takeaways:

  • If you want to rotate your map, you can do it in at least two ways:
  • Affine transformation
  • Oblique Mercator
  • If you use the Affine transformation, you’re just multiplying you coordinates by a number. This will rotate everything and make it plot-able, but you’re now using a custom, undefined coordinate reference system.
  • If you project the underlying spatial data into some sort of rotated coordinate reference system, like the oblique Mercator outlined here, you have a defined coordinate reference system and can continue to treat your polygon as a spatial object
  • When plotting with ggplot2, you can continue to use intuitive latitude/longitude to “zoom in”, but you will likely have to provide a default CRS when the units are not in degrees
  • Using a rotated projection and a rectangular view port will likely produce unintuitive results – be prepared to work iteratively and fudge things around.