| east | north | elevation | magnitude | slab |
|---|---|---|---|---|
| 181.62 | -20.42 | -562 | 4.8 | 540-630 km |
| 181.03 | -20.62 | -650 | 4.2 | 630-720 km |
| 184.10 | -26.00 | -42 | 5.4 | 0-90 km |
| 181.66 | -17.97 | -626 | 4.1 | 540-630 km |
| 181.96 | -20.42 | -649 | 4.0 | 630-720 km |
35 Map
Where did it happen? Some data answers that with a place on the earth, and a place is given by two numbers. Longitude says how far east or west of Greenwich a place is, and latitude how far north or south of the equator. Both are angles in degrees, and south and west are negative.
Most plots in this book have placed their marks with two numbers, one on x and one on y. So a map is not a new kind of picture. It is the same sentence you already write, drawn in a space that treats the two numbers as angles on a sphere.
quakes_fiji records earthquakes near Fiji, one row per earthquake:
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())“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 only word that makes it a map. The axes are labeled in degrees, because that is what the columns hold. Every north value is negative, because Fiji lies south of the equator. east keeps counting past 180 instead of turning negative, so 185 means 175 degrees west.
In the web edition, the buttons under the plot work here too: the two magnifiers zoom in and out, and the frame button shows the whole map again. Once you have zoomed in, dragging moves the map around inside the panel, and the frame button undoes both. A map needs those controls more than most plots do, because a map is full of detail at every zoom level, from a whole ocean down to one street. Zooming changes only the view: it magnifies the picture and changes nothing the plot claims.
Zooming cannot change the projection, the method that flattens the round earth onto the page. You choose the projection, and the next section shows what each choice keeps and what it loses.
35.1 Choosing a projection
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. You choose which distortion you accept.
Two properties matter most: the area of a region, and the shape of a small region. A projection can keep one of them, never both. A projection that keeps every angle keeps every small shape, so preserve takes "area" or "angle":
| 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 |
| 73.260 | 37.495 | Afghanistan | Asia | p001 |
(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\"")))“Given the world borders: paths, x is lon, y is lat, grouped by piece, in the map space preserving area, above the same preserving angle.”
Look at Greenland, at the top left of each map, and compare it with Africa. Under preserve = "area", Greenland is small, which is correct: Africa is about fourteen times larger. Under preserve = "angle", the Mercator projection most web maps use, Greenland is drawn as large as Africa. The "angle" map keeps the true shape of every small region, and the areas it draws are wrong.
Both plots name lon on x, so / treats that as one axis: one scale, and one panel width. The projection sets each height, and the two heights differ. Keeping every small shape is what stretches the second one north and south.
preserve = "area" is the default, because a map is usually read by comparing how much area each region covers. A projection that draws the far north too large makes every region there look bigger than it is.
The world map above comes from a table like any other, and the next section says how that table is laid out.
35.2 Drawing the outlines
To draw a boundary of your own, you need a table laid out the way the engine reads it. world_borders holds one row for every point along a coastline or a border, and it repeats the country and the continent on each of those rows.
path connects rows in the order the table gives them, which is exactly what tracing a coastline needs. A ring is one closed loop of points, ending where it returns to its starting point. group(piece) says where one ring ends and the next begins. A country is not always a single ring. An island is a ring of its own, and a country that encloses another needs a second ring for the hole. So piece has one value for each ring, not one for each country.
Without group(piece), path would join the last point of one country to the first point of the next. That would draw a line straight across the ocean between them. Grouping by country is not enough either, because a path joins every row of its group in order: South Africa’s outline would gain a line to the ring around Lesotho.
35.3 The second layer is the data
The chapter’s first plot drew the earthquakes with no coastline, so nothing said where on the earth they were. The outline supplies that, and here it is the background rather than the subject. Your own data goes on top of it, which is ordinary layering. The outline is one data() and one mark, and the earthquakes 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 near Fiji")(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 near Fiji"))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 near Fiji")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 near Fiji"))“Given the world borders: paths, x is lon, y is lat, grouped by piece; then given quakes Fiji: points, x is east, y is north, in the map space.”
The earthquakes are now drawn on a world map, north of New Zealand and east of Australia, so a reader can see where they are. Nothing in the sentence is new: two tables, two marks, and the second data() binds to the mark that follows it.
35.4 A quantity goes on a channel
The earthquakes differ in magnitude, and the picture so far shows only where they were. In a flat plot, a cube or a polar plot, an axis can carry a quantity. A bar has its base on one axis and its length along the other. A map has no axis left for a quantity, because longitude and latitude use both.
So a quantity moves to a channel. Here size carries each earthquake’s 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")(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"))“Given the world borders: paths, x is lon, y is lat, grouped by piece; then given quakes Fiji: points, x is east, y is north, sized by magnitude, in the map space.”
That plot has a name, the proportional-symbol map, and no single atom draws it. It is point with size bound, in this space.
Color works the same way. Here the column bound to it is a category, so each continent is given its own hue:
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"))“Given the world borders: paths, x is lon, y is lat, grouped by piece, color by continent, in the map space.”
color reads the category exactly as it does on a flat plot. The space changed; the channel did not. The legend’s seventh entry, Seven seas (open ocean), is not a continent. The outline comes from Natural Earth, which puts the French Southern and Antarctic Lands under that name. They are islands in the southern Indian Ocean.
35.5 Meridians and parallels
A map often needs a reference line, and the most common one is the equator. A rule draws a line at one position and spans the other axis. On a flat plot that is a reference line. Here, a rule at one latitude is a parallel, and a rule at one longitude is a meridian. The plot below draws one parallel, the equator:
| lat |
|---|
| 0 |
data(world_borders) + path + x(lon) + y(lat) + group(piece) +
style(color = "#c9ccd6") +
data(equator) + rule + y(lat) + style(color = "#d4674f") +
map() + title("The equator")(data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
style(color = "#c9ccd6") +
data(equator) + rule + y(col.lat) + style(color = "#d4674f") +
map() + title("The equator"))data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
style(color = "#c9ccd6") + data(equator) + rule + y(:lat) +
style(color = "#d4674f") + map() + title("The equator")plot(data(world_borders), path, x(col.lon), y(col.lat), group(col.piece),
style({ color: "#c9ccd6" }), data(equator), rule, y(col.lat),
style({ color: "#d4674f" }), map(), title("The equator"))“Given the world borders: paths, x is lon, y is lat, grouped by piece; then given the equator: a rule, y is lat, in the map space.”
A rule takes its position from a column, like every other mark, so the equator comes from a one-row table. Nothing was added to the grammar to draw it: the sentence that puts a reference line on a scatter puts the equator on a map. The space gives the mark a new meaning, and the mark itself is unchanged.
A meridian is drawn the same way, from a table of longitudes. The meridians table holds five of them, 60 degrees apart:
| lon |
|---|
| -120 |
| -60 |
| 0 |
| 60 |
| 120 |
data(world_borders) + path + x(lon) + y(lat) + group(piece) +
style(color = "#c9ccd6") +
data(meridians) + rule + x(lon) + style(color = "#d4674f") +
map() + title("Meridians, 60 degrees apart")(data(world_borders) + path + x(col.lon) + y(col.lat) + group(col.piece) +
style(color = "#c9ccd6") +
data(meridians) + rule + x(col.lon) + style(color = "#d4674f") +
map() + title("Meridians, 60 degrees apart"))data(world_borders) + path + x(:lon) + y(:lat) + group(:piece) +
style(color = "#c9ccd6") + data(meridians) + rule + x(:lon) +
style(color = "#d4674f") + map() + title("Meridians, 60 degrees apart")plot(data(world_borders), path, x(col.lon), y(col.lat), group(col.piece),
style({ color: "#c9ccd6" }), data(meridians), rule, x(col.lon),
style({ color: "#d4674f" }), map(),
title("Meridians, 60 degrees apart"))“Given the world borders: paths, x is lon, y is lat, grouped by piece; then given the meridians: rules, x is lon, in the map space.”
Each meridian runs from the bottom of the map to the top. The one at 0 degrees is straight. The others curve toward it near the top and the bottom. This map keeps every region’s area, and to do that it draws the meridians closer together near the poles. Under map(preserve = "angle"), every meridian is a straight vertical line.
35.6 Naming places
A coastline alone does not say which part of the world it is. A few city names tell the reader where to look. text writes a string at a position, and a position here is a place:
| lon | lat | name |
|---|---|---|
| -0.13 | 51.51 | London |
| 139.69 | 35.69 | Tokyo |
| -77.04 | 38.91 | Washington |
| 149.13 | -35.28 | Canberra |
| 18.42 | -33.92 | 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 capitals")(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 capitals"))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 capitals")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 capitals"))“Given the world borders: paths, x is lon, y is lat, grouped by piece; then given the capitals: points, x is lon, y is lat; then given the capitals: text, x is lon, y is lat, label by name, in the map space.”
Each city is drawn by two layers: a point for the place, and text above it for the name. style(nudge = "up") moves each name up so that it does not cover its dot.
35.7 What a map refuses
You may want a histogram of the earthquakes by longitude, which on a flat plot is bar * bin. A mark that measures along an axis needs one of its own, and on a map longitude and latitude use both. gog says so rather than drawing something that looks like a map and is not one:
data(quakes_fiji) + bar * bin + x(east) + map()data(quakes_fiji) + bar * bin + x(col.east) + map()data(quakes_fiji) + bar * bin + x(:east) + map()plot(data(quakes_fiji), layer(bar, bin), x(col.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 marks that do draw, and it says what to do instead. Put the quantity on size, as the magnitude map above does, or on color.
A point does draw on a map, but point * bin would put each bin’s count on y, which on a map is latitude. A count is not a place, so that is refused as well:
data(quakes_fiji) + point * bin + x(east) + map()data(quakes_fiji) + point * bin + x(col.east) + map()data(quakes_fiji) + point * bin + x(:east) + map()plot(data(quakes_fiji), layer(point, bin), x(col.east), map())Error:
! gog: `point * bin` writes its own measurement on a position, and a `map()` plot spends both positions on the place: `x` is longitude and `y` is latitude, so the measurement would be drawn as degrees. Drop `map()` to draw `point * bin` flat. To show where rows gather on the map, `path * density + x(<lon>) + y(<lat>) + map()` draws it as contour lines.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
To show where the earthquakes gather, the message points to path * density, which draws their density as contour lines over longitude and latitude.
A category cannot be a position on a map either, because a position there is a place:
| country | continent | year | life | population | gdp |
|---|---|---|---|---|---|
| Afghanistan | Asia | 2007 | 43.828 | 31889923 | 974.5803 |
| Albania | Europe | 2007 | 76.423 | 3600523 | 5937.0295 |
| Algeria | Africa | 2007 | 72.301 | 33333216 | 6223.3675 |
| Angola | Africa | 2007 | 42.731 | 12420476 | 4797.2313 |
| Argentina | Americas | 2007 | 75.320 | 40301927 | 12779.3796 |
data(gapminder_2007) + point + x(continent) + y(life) + map()data(gapminder_2007) + point + x(col.continent) + y(col.life) + map()data(gapminder_2007) + point + x(:continent) + y(:life) + map()plot(data(gapminder_2007), point, x(col.continent), y(col.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.
The projection also sets the panel’s width divided by its height, which theme(ratio = ) would change:
data(quakes_fiji) + point + x(east) + y(north) + map() + theme(ratio = 1)data(quakes_fiji) + point + x(col.east) + y(col.north) + map() + theme(ratio = 1)data(quakes_fiji) + point + x(:east) + y(:north) + map() +
theme(ratio = 1)plot(data(quakes_fiji), point, x(col.east), y(col.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 room the plot takes, use `theme(width =, height =)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
A map drawn with preserve = "area" and stretched into a square panel no longer keeps area, though it still looks like a map. A reader cannot see that error, so the engine refuses ratio on a map instead of accepting the number and ignoring it.
35.8 Where Mercator stops
Some sentences are drawn with a message instead of refused. Mercator has a limit that preserve = "area" does not. It would draw the poles infinitely far from the equator, so it has to stop before it reaches them. It stops at 85.05 degrees north and south. A row beyond that is drawn at the edge, and gog reports how many rows were moved that way:
| lon | lat |
|---|---|
| 0 | 60 |
| 15 | 78 |
| -45 | 83 |
| 10 | 89 |
data(far_north) + point + x(lon) + y(lat) + map(preserve = "angle")data(far_north) + point + x(col.lon) + y(col.lat) + map(preserve = "angle")data(far_north) + point + x(:lon) + y(:lat) + map(preserve = "angle")plot(data(far_north), point, x(col.lon), y(col.lat),
map({ preserve: "angle" }))gog: `map(preserve = "angle")` sends the poles infinitely far away, so it stops at 85.05° — 1 row(s) beyond that were drawn at the edge rather than where they belong. Use `map(preserve = "area")`, which reaches both poles, or leave those rows out on purpose.
Read the message above the plot. One row is 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.
35.9 Filling the regions: the choropleth
Every country so far has been drawn as an outline. Filling each country by a quantity is the most familiar kind of map, and it has a name: the choropleth. The map below fills each country by its continent first; the next section fills it by a quantity.
The mark is zone, which shades a region. Everywhere else in this book a zone takes its sides from its own row: a category’s slot, a pair of bounds, a bin, a density cell, or a region that partition or flow computes. A country’s outline is different: it is the only case where many rows make 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"))“Given the world borders: zones, x is lon, y is lat, grouped by country, color by continent, in the map space.”
Compare that with the earlier map that colored each continent. Apart from the title, 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.
35.10 Life expectancy on the map
Where in the world do people live longest? color accepts a quantity as well as a category, so the same sentence can shade each country by life expectancy. That column comes from a table you already know: gapminder_2007, the same 142 countries that the chapter A first plot draws.
Joining two tables is the host language’s job, not the grammar’s, so this book has made the join for you. The filled table holds the outline of each country whose name also appears in gapminder_2007, with that country’s life value:
| lon | lat | country | continent | piece | life |
|---|---|---|---|---|---|
| 66.519 | 37.363 | Afghanistan | Asia | p001 | 43.828 |
| 69.196 | 37.151 | Afghanistan | Asia | p001 | 43.828 |
| 70.807 | 38.486 | Afghanistan | Asia | p001 | 43.828 |
| 71.845 | 36.738 | Afghanistan | Asia | p001 | 43.828 |
| 73.260 | 37.495 | Afghanistan | Asia | p001 | 43.828 |
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")(data(world_borders) + zone + x(col.lon) + y(col.lat) + group(col.country) +
style(color = "#e6e6ec") +
data(filled) + zone + x(col.lon) + y(col.lat) + group(col.country) + color(col.life) +
map() + title("Life expectancy at birth, 2007"))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")plot(data(world_borders), zone, x(col.lon), y(col.lat),
group(col.country), style({ color: "#e6e6ec" }), data(filled), zone,
x(col.lon), y(col.lat), group(col.country), color(col.life), map(),
title("Life expectancy at birth, 2007"))Two layers again. The first draws all 176 countries in pale gray, and the second colors the 120 countries that filled holds. A name matches only when both tables spell it the same way. The outline says United States of America and gapminder_2007 says United States, so the United States stays gray, and so do 14 other countries whose names the two tables spell differently. Without the gray layer, the 56 unmatched countries would simply be absent, and a reader could not tell “no match” from “no country”.
Compare the map with the scatter plot in A first plot, which shows life expectancy against GDP per person, one point per country. The map shows that same life column. It answers a question the scatter cannot: where.
35.11 Two years on one color scale
The map above shows a single year. Did life expectancy rise everywhere between 1957 and 2007, or only in some places? To compare two years, draw the same map once for each year, as two panels of one plot. gm_eras holds life expectancy for both years, one row per country and year:
| country | continent | year | life | population | gdp | era |
|---|---|---|---|---|---|---|
| Afghanistan | Asia | 1957 | 30.332 | 9240934 | 820.8530 | 1957 |
| Afghanistan | Asia | 2007 | 43.828 | 31889923 | 974.5803 | 2007 |
| Albania | Europe | 1957 | 59.280 | 1476505 | 1942.2842 | 1957 |
| Albania | Europe | 2007 | 76.423 | 3600523 | 5937.0295 | 2007 |
| Algeria | Africa | 1957 | 45.685 | 10270856 | 3013.9760 | 1957 |
It stores the year twice: year is a number, and era is the same year as a label. A facet needs the label, because a facet column names its panels (Faceting).
The eras table is made the same way as filled, once for each year. It holds two copies of the outline, and its era column says which year each copy shows:
| lon | lat | country | continent | piece | era | life |
|---|---|---|---|---|---|---|
| 66.519 | 37.363 | Afghanistan | Asia | p001 | 1957 | 30.332 |
| 69.196 | 37.151 | Afghanistan | Asia | p001 | 1957 | 30.332 |
| 70.807 | 38.486 | Afghanistan | Asia | p001 | 1957 | 30.332 |
| 71.845 | 36.738 | Afghanistan | Asia | p001 | 1957 | 30.332 |
| 73.260 | 37.495 | Afghanistan | Asia | p001 | 1957 | 30.332 |
data(world_borders) + zone + x(lon) + y(lat) + group(country) +
style(color = "#e6e6ec") +
data(eras) + zone + x(lon) + y(lat) + group(country) + color(life) +
palette("viridis") + map() +
title("Life expectancy at birth, 1957 and 2007") / facet(era)(data(world_borders) + zone + x(col.lon) + y(col.lat) + group(col.country) +
style(color = "#e6e6ec") +
data(eras) + zone + x(col.lon) + y(col.lat) + group(col.country) + color(col.life) +
palette("viridis") + map() +
title("Life expectancy at birth, 1957 and 2007") / facet(col.era))data(world_borders) + zone + x(:lon) + y(:lat) + group(:country) +
style(color = "#e6e6ec") + data(eras) + zone + x(:lon) + y(:lat) +
group(:country) + color(:life) + palette("viridis") + map() +
title("Life expectancy at birth, 1957 and 2007") / facet(:era)plot(data(world_borders), zone, x(col.lon), y(col.lat),
group(col.country), style({ color: "#e6e6ec" }), data(eras), zone,
x(col.lon), y(col.lat), group(col.country), color(col.life),
palette("viridis"), map(),
title("Life expectancy at birth, 1957 and 2007"), down(col.era))gog: table `world_borders` has no column `era`, so its layer is drawn in every panel. If it should be split too, add `era` to that table.
“Given the world borders: zones, x is lon, y is lat, grouped by country; then given the eras table: zones, x is lon, y is lat, grouped by country, colored by life, with the viridis palette, in the map space, split into panel rows by era.”
/ facet(era) stacks the two panels, one above the other. In the default projection a world map is about twice as wide as it is tall, so side by side each map would be about half as wide. Both panels share one color scale, and that is what makes them comparable: the same shade means the same life expectancy in 1957 and in 2007.
palette("viridis") colors the shortest life expectancy dark purple and the longest yellow. In the map above, dark meant the longest. Dark purple is clearly different from the pale gray of the unmatched countries, so a short life expectancy never looks like a missing value.
Most countries are greener or yellower in 2007 than in 1957. Counted from the table, life expectancy rose in 117 of the 120 colored countries, and the median rose from 48 years to 72. Life expectancy fell only in Lesotho, Zambia and Zimbabwe, all three in the south of Africa.
The gray layer appears in both panels. Its table, world_borders, has no era column, so gog draws that layer in every panel and says so in a message. A layer without the facet column explains the rule.
35.12 Rings, and the country inside another country
A zone groups by country, not by piece, so nothing in its sentence says where one ring ends. gog finds each ring by closure: 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.
Finding rings by closure is what lets a country contain a hole. Lesotho, one of the three countries above, lies entirely inside South Africa, and South Africa’s boundary has a second ring around Lesotho. The enclave table holds the outlines of the two countries, each with its 2007 life expectancy:
| lon | lat | country | continent | piece | life |
|---|---|---|---|---|---|
| 28.978 | -28.956 | Lesotho | Africa | p145 | 42.592 |
| 29.325 | -29.257 | Lesotho | Africa | p145 | 42.592 |
| 28.107 | -30.546 | Lesotho | Africa | p145 | 42.592 |
| 26.999 | -29.876 | Lesotho | Africa | p145 | 42.592 |
| 28.074 | -28.851 | Lesotho | Africa | p145 | 42.592 |
data(enclave) + zone + x(lon) + y(lat) + group(country) + color(life) +
map() + title("Lesotho is not painted on top of South Africa")(data(enclave) + zone + x(col.lon) + y(col.lat) + group(col.country) + color(col.life) +
map() + title("Lesotho is not painted on top of South Africa"))data(enclave) + zone + x(:lon) + y(:lat) + group(:country) +
color(:life) + map() +
title("Lesotho is not painted on top of South Africa")plot(data(enclave), zone, x(col.lon), y(col.lat), group(col.country),
color(col.life), map(),
title("Lesotho is not painted on top of South Africa"))Lesotho is a hole in South Africa, not a patch laid over it. The difference matters because the two countries have different values. A patch is right only when it is drawn after the region beneath it, and nothing in the sentence says which region is drawn first.
Real boundary data is not always written this carefully. If a ring in your data does not close, gog cannot tell where one ring ends and the next begins. It draws the region, warns you that the ring ends had to be guessed, and names what to fix.
35.13 Counties: one country in finer detail
Every map so far has given each country one value. One value hides how much the places inside a country differ. Each state of the United States is divided into areas called counties. A county’s poverty rate is the estimated percent of its people who live in poverty. In 2023 the median of the county rates was 13.6%. The lowest was 3.3%, and the highest was 49.6%, nearly half. A map of the counties shows where the high rates and the low rates are.
The us_counties table holds 3,109 counties: those of the 48 states between Canada and Mexico, plus the District of Columbia, which counts as one county. Alaska and Hawaii are left out. Many maps of the United States move them into small boxes beside the other states. In gog, a map draws every place at its own longitude and latitude. Alaska lies far to the northwest and Hawaii far to the southwest, so including them would make the 48 states much smaller.
The outlines and the rates both come from the US Census Bureau, for 2023. Like world_borders, the table has one row for each point of an outline, and piece names each ring. poverty holds each county’s rate, repeated on every row of its outline, so the map needs no join.
The sentence below is a choropleth, built like the map in Life expectancy on the map. group(county) says which rows make one county, and color(poverty) shades each county by its rate. Three more choices differ from that map. First, palette() names four colors, from seashell (almost white) to saddlebrown, so a county with a higher rate is drawn darker.
Second, preserve = "angle" draws the map with Mercator. The default, preserve = "area", draws only the meridian at 0 degrees as a straight vertical line, as Meridians and parallels showed. The others curve more the farther they lie from it. So the United States, at about 100 degrees west, would be drawn slanted. Mercator draws every meridian straight.
Third, theme(height = 470) sets the image’s height to 470 pixels, shorter than the default. A map keeps the proportions its projection gives it, so it never stretches to fill a tall image. Without the setting, empty space would be left above and below the map.
| lon | lat | county | piece | poverty |
|---|---|---|---|---|
| -86.918 | 32.664 | Autauga County, AL | p00001 | 11.7 |
| -86.413 | 32.707 | Autauga County, AL | p00001 | 11.7 |
| -86.411 | 32.410 | Autauga County, AL | p00001 | 11.7 |
| -86.497 | 32.344 | Autauga County, AL | p00001 | 11.7 |
| -86.781 | 32.392 | Autauga County, AL | p00001 | 11.7 |
data(us_counties) + zone + x(lon) + y(lat) + group(county) + color(poverty) +
palette(c("seashell", "sandybrown", "chocolate", "saddlebrown")) +
map(preserve = "angle") +
title("Poverty rate by county, 2023") + theme(height = 470)(data(us_counties) + zone + x(col.lon) + y(col.lat) + group(col.county) + color(col.poverty) +
palette(["seashell", "sandybrown", "chocolate", "saddlebrown"]) +
map(preserve = "angle") +
title("Poverty rate by county, 2023") + theme(height = 470))data(us_counties) + zone + x(:lon) + y(:lat) + group(:county) +
color(:poverty) +
palette(["seashell", "sandybrown", "chocolate", "saddlebrown"]) +
map(preserve = "angle") + title("Poverty rate by county, 2023") +
theme(height = 470)plot(data(us_counties), zone, x(col.lon), y(col.lat), group(col.county),
color(col.poverty),
palette(["seashell", "sandybrown", "chocolate", "saddlebrown"]),
map({ preserve: "angle" }), title("Poverty rate by county, 2023"),
theme({ height: 470 }))“Given the US counties: zones, x is lon, y is lat, grouped by county, color by poverty, with a palette of seashell, sandybrown, chocolate and saddlebrown, in the map space preserving angle.”
Mercator keeps the shape of every county, but not its size. It draws a county in South Dakota about 1.6 times as large as a county of the same area in south Texas. That is why preserve = "area" is the default, as Choosing a projection explains. So compare the counties on this map by their colors, not by how much of the map each dark region covers.
The darkest counties form three regions: along the lower Mississippi River, in the mountains of eastern Kentucky, and in South Dakota. Issaquena County, in the state of Mississippi beside the river, had the highest rate of all, 49.6%. Across the river, East Carroll Parish in Louisiana had 46.5%. Louisiana calls its counties parishes. In eastern Kentucky, Martin County had 48.1%. South Texas is lighter than these three regions. Along the border with Mexico, the six counties from Maverick County southeast to Cameron County had between 22% and 31%. That is still higher than nine counties in ten.
In South Dakota, the darkest counties lie on reservations, areas of land reserved for Native American nations. Ziebach County had 46.2%, and Oglala Lakota County had 37.1%. South Dakota also had the lowest rate of all: 3.3% in Lincoln County, which holds the southern part of the city of Sioux Falls. A map with one color for each state would give all of South Dakota one color. It would show neither Ziebach County’s rate nor Lincoln County’s.
35.14 One state over three elections
The poverty map above shows one year. To compare the same places from one election to the next, draw one map for each election, side by side: a map can be split into panels like any other plot. The ohio_turnout table holds Ohio’s 88 counties three times, once for each presidential election: 2016, 2020 and 2024. As in us_counties, each row is one point of a county’s outline, and the election column says which election the row belongs to.
A county’s turnout is the percent of its citizens aged 18 or over who voted. The number who voted is the count each county reported to the US Election Assistance Commission. The number of citizens is the Census Bureau’s estimate.
| lon | lat | county | piece | election | turnout |
|---|---|---|---|---|---|
| -83.706 | 38.638 | Adams County | p001 | 2016 | 54.8 |
| -83.673 | 39.020 | Adams County | p001 | 2016 | 54.8 |
| -83.612 | 39.019 | Adams County | p001 | 2016 | 54.8 |
| -83.386 | 39.055 | Adams County | p001 | 2016 | 54.8 |
| -83.315 | 39.052 | Adams County | p001 | 2016 | 54.8 |
| facet(election) puts the three elections side by side, one panel each. The three panels share one color scale, as the two years did in Two years on one color scale. axis = FALSE on x and on y leaves out both axes: their numbers, their tick marks and their names. A reader finds a county by its place on the map, not by its longitude and latitude. In three small panels, the axis numbers would take space the maps need. The theme() settings remove the gridlines and the panel’s frame and make each panel white. Each panel then holds only its counties, under a strip that names the election.
data(ohio_turnout) + zone + x(lon, axis = FALSE) + y(lat, axis = FALSE) +
group(county) + color(turnout) + palette("viridis") + map(preserve = "angle") +
theme(grid = "none", frame = "none", background = "white", height = 300) +
title("Turnout by county in Ohio") | facet(election)(data(ohio_turnout) + zone + x(col.lon, axis = False) + y(col.lat, axis = False) +
group(col.county) + color(col.turnout) + palette("viridis") + map(preserve = "angle") +
theme(grid = "none", frame = "none", background = "white", height = 300) +
title("Turnout by county in Ohio") | facet(col.election))data(ohio_turnout) + zone + x(:lon, axis = false) +
y(:lat, axis = false) + group(:county) + color(:turnout) +
palette("viridis") + map(preserve = "angle") +
theme(grid = "none", frame = "none", background = "white", height = 300) +
title("Turnout by county in Ohio") | facet(:election)plot(data(ohio_turnout), zone, x(col.lon, { axis: false }),
y(col.lat, { axis: false }), group(col.county), color(col.turnout),
palette("viridis"), map({ preserve: "angle" }),
theme({ grid: "none", frame: "none", background: "white",
height: 300 }), title("Turnout by county in Ohio"),
across(col.election))“Given the Ohio turnout: zones, x is lon without an axis, y is lat without an axis, grouped by county, color by turnout, with the viridis palette, in the map space preserving angle, split into panel columns by election.”
Counted from the table, turnout was highest in 2020 in 68 of the 88 counties. The median of the county turnouts was 62% in 2016, 66% in 2020 and 64% in 2024. The counties kept nearly the same order: a county with a high turnout in one election had a high turnout in the other two. Delaware County, the brightest county, was the highest in all three elections, with 88% in 2020. It lies in the middle of the state, just north of Columbus, the state capital. Holmes County, the darkest county, lies east of Delaware County. It was the lowest in all three, at 40% to 44%. Many of its people are Amish, members of a Christian group who mostly do not vote. They are counted among the citizens, but most of them are not among the people who voted.
The number of citizens is an average over the five years that end in the election year. In a county that grew quickly, such as Delaware County, that average is smaller than the number on election day. So the percent is a little too high.
35.15 What comes next
A map flattens the sphere so a page can hold it. The next chapter, Globe, draws every mark from this chapter on the sphere itself: the same marks at the same places, seen from one side.