32  Map

Where did it happen? Some data answers that with a place on the earth, and a place is two numbers: longitude, how far east or west, and latitude, how far north or south. Both are angles, measured in degrees.

Two numbers is what every plot in this book has taken. So a map is not a new kind of picture. It is the same sentence you already write, drawn in a space that knows the two numbers are angles on a sphere.

data(quakes_fiji) + point + x(east) + y(north) + map()
data(quakes_fiji) + point + x(col.east) + y(col.north) + map()
data(quakes_fiji) + point + x(:east) + y(:north) + map()
plot(data(quakes_fiji), point, x(col.east), y(col.north), map())
170° 180° -30° -20° North East

“Given quakes Fiji: points, x is east, y is north, in the map space.”

That is a thousand earthquakes recorded near Fiji. x is longitude and y is latitude, and map() is the whole of what makes it a map. The axes read in degrees, because that is what the columns hold.

In the web edition, , + and fit under the plot look closer at a crowded part of it and return to the whole. A map wants that more than most plots, because places are crowded at every scale. Zooming is a view: it magnifies the picture and changes nothing the plot claims.

32.1 Flattening the sphere loses something

The earth is round and the page is flat. No projection can flatten a sphere without distorting it, and the choice is not between a good projection and a bad one. It is between which mistake you prefer.

Two properties matter most, and a projection can keep one of them, never both. preserve says which:

(data(world_borders) + path + x(lon) + y(lat) + group(piece) +
   map(preserve = "area") + title("preserve = \"area\"")) /
  (data(world_borders) + path + x(lon) + y(lat) + group(piece) +
     map(preserve = "angle") + title("preserve = \"angle\""))
((data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
   map(preserve = "area") + title("preserve = \"area\"")) /
  (data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
     map(preserve = "angle") + title("preserve = \"angle\"")))
(data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
  map(preserve = "area") + title("preserve = \"area\"")) /
  (data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
  map(preserve = "angle") + title("preserve = \"angle\""))
below(plot(data(world_borders), path, x(col.lon), y(col.lat),
  group(col.piece), map({ preserve: "area" }),
  title("preserve = \"area\"")),
  plot(data(world_borders), path, x(col.lon), y(col.lat),
  group(col.piece), map({ preserve: "angle" }),
  title("preserve = \"angle\"")))
-50° 50° preserve = "area" Lat -100° 100° -50° 50° preserve = "angle" Lat Lon

Look at Greenland, at the top left of each map, and compare it with Africa. In the first, Greenland is small, which is correct: Africa is about fourteen times larger. In the second, Greenland has grown until it rivals Africa. The second map has kept every small shape’s true form and paid for it in size.

The two are the same width and not the same height, and that is the projection speaking rather than the page. Both cover every degree of longitude, so / gives them one lon axis and one width. Keeping every small shape’s form is what stretches the second one north and south.

preserve = "area" is the default, and the reason is what a reader does with a map. A map is usually read by comparing how much space something takes. A projection that inflates the far north tells the reader something false about every number placed there.

32.2 Drawing the outlines

The world map above is a table like any other. It has one row for every point along a coastline or a border:

knitr::kable(head(world_borders, 4))
lon lat country continent piece
66.519 37.363 Afghanistan Asia p001
69.196 37.151 Afghanistan Asia p001
70.807 38.486 Afghanistan Asia p001
71.845 36.738 Afghanistan Asia p001

path connects rows in the order the table gives them, which is exactly what tracing a coastline needs. group(piece) says where one outline ends and the next begins. A country is not always one closed shape: islands are separate rings, and a country lying wholly inside another is a ring of its own. So piece counts rings rather than countries.

Without that split, path would join the last point of one country to the first point of the next, and draw a line across the ocean between them.

32.3 The second layer is the data

An outline on its own is a backdrop. The plot begins when something is drawn on top of it, and that is ordinary layering: the outline is one data() and one mark, the observations are another.

data(world_borders) + path + x(lon) + y(lat) + group(piece) +
  style(color = "#c9ccd6") +
  data(quakes_fiji) + point + x(east) + y(north) +
  style(color = "tomato", opacity = 0.35, size = 3) +
  map() + title("A thousand earthquakes, and where Fiji is")
(data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
  style(color = "#c9ccd6") +
  data(quakes_fiji) + point + x(col.east) + y(col.north) +
  style(color = "tomato", opacity = 0.35, size = 3) +
  map() + title("A thousand earthquakes, and where Fiji is"))
data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
  style(color = "#c9ccd6") + data(quakes_fiji) + point + x(:east) +
  y(:north) + style(color = "tomato", opacity = 0.35, size = 3) + map() +
  title("A thousand earthquakes, and where Fiji is")
plot(data(world_borders), path, x(col.lon), y(col.lat), group(col.piece),
  style({ color: "#c9ccd6" }), data(quakes_fiji), point, x(col.east),
  y(col.north), style({ color: "tomato", opacity: 0.35, size: 3 }), map(),
  title("A thousand earthquakes, and where Fiji is"))
-100° 100° -50° 50° A thousand earthquakes, and where Fiji is Lat Lon

The earthquakes now sit somewhere a reader can find. Nothing in the sentence is new: two tables, two marks, and the second data() binds to the mark that follows it.

32.4 A quantity goes on a channel

Every other space leaves you an axis to measure along. A bar stands on one axis and measures up the other. A map has no axis to spare, because longitude and latitude have taken both.

So a quantity moves to a channel. Size each place by it:

data(world_borders) + path + x(lon) + y(lat) + group(piece) +
  style(color = "#c9ccd6") +
  data(quakes_fiji) + point + x(east) + y(north) + size(magnitude) +
  style(color = "tomato", opacity = 0.4) +
  map() + title("Sized by magnitude")
(data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
  style(color = "#c9ccd6") +
  data(quakes_fiji) + point + x(col.east) + y(col.north) + size(col.magnitude) +
  style(color = "tomato", opacity = 0.4) +
  map() + title("Sized by magnitude"))
data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
  style(color = "#c9ccd6") + data(quakes_fiji) + point + x(:east) +
  y(:north) + size(:magnitude) + style(color = "tomato", opacity = 0.4) +
  map() + title("Sized by magnitude")
plot(data(world_borders), path, x(col.lon), y(col.lat), group(col.piece),
  style({ color: "#c9ccd6" }), data(quakes_fiji), point, x(col.east),
  y(col.north), size(col.magnitude),
  style({ color: "tomato", opacity: 0.4 }), map(),
  title("Sized by magnitude"))
-100° 100° -50° 50° Sized by magnitude Lat Lon Magnitude 4.00 5.20 6.40

That plot has a name, the proportional-symbol map, and there is no atom that spells it. It is point with size bound, in this space.

Color works the same way, and reads a category rather than a quantity:

data(world_borders) + path + x(lon) + y(lat) + group(piece) +
  color(continent) + map() +
  title("Each continent in its own color")
(data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
  color(col.continent) + map() +
  title("Each continent in its own color"))
data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
  color(:continent) + map() + title("Each continent in its own color")
plot(data(world_borders), path, x(col.lon), y(col.lat), group(col.piece),
  color(col.continent), map(), title("Each continent in its own color"))
-100° 100° -50° 50° Each continent in its own color Lat Lon Continent Asia Europe Africa South America Oceania North America Seven seas (open ocean)

32.5 Meridians and parallels

A rule draws a line at one position and spans the axis it does not name. Flat, that is a reference line. Here, a rule at one longitude is a meridian, and a rule at one latitude is a parallel:

lines_of <- data.frame(lat = 0)

data(world_borders) + path + x(lon) + y(lat) + group(piece) +
  style(color = "#c9ccd6") +
  data(lines_of) + rule + y(lat) + style(color = "#d4674f") +
  map() + title("The equator")
lines_of = {"lat": [0]}
(data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
  style(color = "#c9ccd6") +
  data(lines_of) + rule + y(col.lat) + style(color = "#d4674f") +
  map() + title("The equator"))
lines_of = (lat = [0],)
data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
  style(color = "#c9ccd6") + data(lines_of) + rule + y(:lat) +
  style(color = "#d4674f") + map() + title("The equator")
const lines_of = { lat: [0] };
plot(data(world_borders), path, x(col.lon), y(col.lat), group(col.piece),
  style({ color: "#c9ccd6" }), data(lines_of), rule, y(col.lat),
  style({ color: "#d4674f" }), map(), title("The equator"))
-100° 100° -50° 50° The equator Lat Lon

A rule takes its position from a column, like every other mark, so the line above comes from a one-row table. That is the same way a reference line is drawn on any other plot.

Nothing was added to the grammar to draw that. The same sentence that puts a reference line on a scatter puts the equator on a map, because the space bends what the mark means without changing what the mark is.

32.6 Naming places

text writes a string at a position, and a position here is a place:

capitals <- data.frame(
  lon  = c(-0.13, 139.69, -74.01, 151.21, 18.42),
  lat  = c(51.51,  35.69,  40.71, -33.87, -33.92),
  name = c("London", "Tokyo", "New York", "Sydney", "Cape Town"))

data(world_borders) + path + x(lon) + y(lat) + group(piece) +
  style(color = "#c9ccd6") +
  data(capitals) + point + x(lon) + y(lat) +
  style(color = "#3d5a80", size = 5) +
  data(capitals) + text + x(lon) + y(lat) + label(name) +
  style(nudge = "up", size = 11) +
  map() + title("Five cities")
capitals = {"lon": [-0.13, 139.69, -74.01, 151.21, 18.42], "lat": [51.51,  35.69,  40.71, -33.87, -33.92], "name": ["London", "Tokyo", "New York", "Sydney", "Cape Town"]}
(data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
  style(color = "#c9ccd6") +
  data(capitals) + point + x(col.lon) + y(col.lat) +
  style(color = "#3d5a80", size = 5) +
  data(capitals) + text + x(col.lon) + y(col.lat) + label(col.name) +
  style(nudge = "up", size = 11) +
  map() + title("Five cities"))
capitals = (lon = [-0.13, 139.69, -74.01, 151.21, 18.42], lat = [51.51, 35.69, 40.71, -33.87, -33.92], name = ["London", "Tokyo", "New York", "Sydney", "Cape Town"],)
data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
  style(color = "#c9ccd6") + data(capitals) + point + x(:lon) + y(:lat) +
  style(color = "#3d5a80", size = 5) + data(capitals) + text + x(:lon) +
  y(:lat) + label(:name) + style(nudge = "up", size = 11) + map() +
  title("Five cities")
const capitals = { lon: [-0.13, 139.69, -74.01, 151.21, 18.42], lat: [51.51, 35.69, 40.71, -33.87, -33.92], name: ["London", "Tokyo", "New York", "Sydney", "Cape Town"] };
plot(data(world_borders), path, x(col.lon), y(col.lat), group(col.piece),
  style({ color: "#c9ccd6" }), data(capitals), point, x(col.lon),
  y(col.lat), style({ color: "#3d5a80", size: 5 }), data(capitals), text,
  x(col.lon), y(col.lat), label(col.name),
  style({ nudge: "up", size: 11 }), map(), title("Five cities"))
London Tokyo New York Sydney Cape Town -100° 100° -50° 50° Five cities Lat Lon

32.7 What a map refuses

A mark that measures along an axis has no axis here. gog says so rather than drawing something that looks like a map and is not one:

data(quakes_fiji) + bar * count + x(east) + map()
Error:
! gog: `bar` measures along an axis, and a `map()` plot has none to spare — longitude and latitude use both. Drop `map()` to draw `bar` flat, or use `point`, `text`, `path`, `rule`, or `zone`. To carry a quantity on a map, put it on a channel instead of an axis: `point + size(<column>)` sizes each place by it, and `color(<column>)` shades it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message names the four marks that do draw, and it names the way out: put the quantity on size or color, which is what the two plots above did.

A position on a map is a place, so a category cannot be one:

data(gapminder_2007) + point + x(continent) + y(life) + map()
Error:
! gog: `x(continent)` is a category, and a `map()` position is a place — `x` is longitude and `y` is latitude, both in degrees. A category has neither. Give the map the coordinates and put `continent` on a channel that decodes it: `color(continent)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

And the panel’s shape belongs to the projection:

data(quakes_fiji) + point + x(east) + y(north) + map() + theme(ratio = 1)
Error:
! gog: `theme(ratio = )` shapes a panel, and a `map()` panel is shaped by the projection — an equal-area map stretched to fit a box is no longer equal-area, though it still looks like a map. Drop it. To change how much of the page the plot takes, use `theme(width =, height =)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

An equal-area map stretched to fit a box is no longer equal-area, though it still looks like a map. That is the kind of error a reader cannot see, so the engine refuses it instead of accepting the number and ignoring it.

32.8 Where Mercator stops

preserve = "angle" sends the poles infinitely far away, so it has to stop short of them. It stops at 85.05 degrees, and it says how many rows it could not reach rather than moving them in silence:

far_north <- data.frame(
  lon = c(0, 15, -45, 10),
  lat = c(60, 78, 83, 89))

data(far_north) + point + x(lon) + y(lat) + map(preserve = "angle")
far_north = {"lon": [0, 15, -45, 10], "lat": [60, 78, 83, 89]}
data(far_north) + point + x(col.lon) + y(col.lat) + map(preserve = "angle")
far_north = (lon = [0, 15, -45, 10], lat = [60, 78, 83, 89],)
data(far_north) + point + x(:lon) + y(:lat) + map(preserve = "angle")
const far_north = { lon: [0, 15, -45, 10], lat: [60, 78, 83, 89] };
plot(data(far_north), point, x(col.lon), y(col.lat),
  map({ preserve: "angle" }))
-40° -20° 60° 70° 80° Lat Lon

Read the message above the plot. One row sits beyond the limit and was drawn at the edge instead of where it belongs. Nothing was dropped, because dropping it would misreport how many places there are. preserve = "area" reaches both poles and has no such limit.

32.9 Filling the regions: the choropleth

Everything so far has drawn outlines. Filling each country by a value is the plot most people picture when they hear “map”, and it has a name: the choropleth.

The mark is zone, which shades a region. Everywhere else in this book a zone finds its sides from its own row: a category’s slot, bounds, bin or density. A coastline is a fifth way, and it is the first that arrives as many rows for one shape. So the sentence needs a word for which rows belong to which region, and group is that word already:

data(world_borders) + zone + x(lon) + y(lat) + group(country) +
  color(continent) + map() + title("Every country, filled by continent")
(data(world_borders) + zone + x(col.lon) + y(col.lat) + group(col.country) +
  color(col.continent) + map() + title("Every country, filled by continent"))
data(world_borders) + zone + x(:lon) + y(:lat) + group(:country) +
  color(:continent) + map() + title("Every country, filled by continent")
plot(data(world_borders), zone, x(col.lon), y(col.lat),
  group(col.country), color(col.continent), map(),
  title("Every country, filled by continent"))
-100° 100° -50° 50° Every country, filled by continent Lat Lon Continent Asia Europe Africa South America Oceania North America Seven seas (open ocean)

Compare that with the outline map earlier. Only two things changed: path became zone, and group(piece) became group(country). A path traces one ring at a time, so it groups by ring. A zone fills a whole country, so it groups by country.

32.10 A value on the map

color reads a number as readily as a category, and that is the choropleth proper. The value comes from a table you already know: gapminder_2007, the same 142 countries the scatter chapters draw.

Joining two tables is the host language’s job, not the grammar’s. In R that is one line with match():

filled <- world_borders
filled$life <- gapminder_2007$life[match(filled$country,
                                         gapminder_2007$country)]
filled <- filled[!is.na(filled$life), ]

data(world_borders) + zone + x(lon) + y(lat) + group(country) +
  style(color = "#e6e6ec") +
  data(filled) + zone + x(lon) + y(lat) + group(country) + color(life) +
  map() + title("Life expectancy at birth, 2007")
-100° 100° -50° 50° Life expectancy at birth, 2007 Lat Lon Life 82.60 62.34 42.08

Two layers again, and the first one is doing real work. gapminder_2007 covers 120 of the 176 countries in the outline table, so the pale gray layer underneath is what the reader sees where there is no number. Without it those countries would simply be absent, and a reader could not tell “no data” from “no country”.

Read the map and the scatter together. The chapter on first plots shows life expectancy against income as a cloud of points. Here is the same column, and the map answers a question the scatter cannot: where.

32.11 Rings, and the country inside another country

A region is made of rings, and the rings are found by a rule rather than by a column: a ring ends where it returns to the point it started from. Boundary data is written that way, so nothing extra has to be said.

That rule is what lets a country contain a hole. Lesotho lies entirely inside South Africa, and South Africa’s boundary has a second ring around it:

enclave <- world_borders[world_borders$country %in%
                           c("South Africa", "Lesotho"), ]
enclave$life <- gapminder_2007$life[match(enclave$country,
                                          gapminder_2007$country)]

data(enclave) + zone + x(lon) + y(lat) + group(country) + color(life) +
  map() + title("Lesotho is not painted on top of South Africa")
20° 25° 30° -30° -25° Lesotho is not painted on top of South Africa Lat Lon Life 49.34 45.97 42.59

Lesotho is a hole in South Africa, not a patch laid over it. The difference matters because the two countries have different values, and a patch would be correct only if it happened to be drawn second.

If a ring in your data does not close, gog cannot tell where one ring ends and the next begins. It says so rather than guessing quietly, and names what to fix.