33  Polar

Which way does the wind blow most often? Some questions do not fit a flat page. Every plot so far has been drawn on one: an x running across, a y running up. That is one coordinate space, flat, and the kernel names six more. It is also the wrong shape for a whole class of variables. Compass direction, the hour of the day, the month of the year: in each of them the last value is the neighbor of the first. A straight axis has no way to show that.

Two of the oldest statistical graphics already live in this space. Playfair’s Statistical Breviary of 1801 cut a circle into sectors to show the Turkish empire’s territory, and that is the first pie chart (Playfair, 1801). Florence Nightingale’s 1858 diagram of what killed British soldiers in the Crimea (Nightingale, 1858) is a bar chart in polar coordinates, and it has three names: a coxcomb, a rose, or, most precisely, a polar area diagram. That third name is the one carrying information. Her wedges put each month’s death count into the sector’s area, so their radius grows as the square root of the number. A polar bar here maps the value straight to the radius. Both are honest drawings and they are not the same drawing, which is worth knowing before you set one beside the other.

The line version has collected even more names: radar plot, spider plot, star plot, and Kiviat diagram in engineering. As with the rose and the pie, none of them is an atom. Each is a mark and a channel or two with polar() at the end, which is what the rest of this chapter is about.

33.1 The axis that breaks a circle

How often the wind blew from each direction is a count per compass point, so start with the plot you know. It shows what a straight axis loses. Here is a season of wind observations as an ordinary bar chart, one bar per compass point:

winds: first 5 of 264 rows
direction bearing speed season
N 19.612260 10.1 Winter
N 357.049499 11.0 Winter
N 349.709737 13.8 Winter
N 2.602499 7.4 Winter
N 338.210715 3.7 Summer
data(winds) + bar * count + x(direction)
data(winds) + bar * count + x(col.direction)
data(winds) + bar * count + x(:direction)
plot(data(winds), layer(bar, count), x(col.direction))
N NE E SE S SW W NW 0 20 40 60 Count Direction

“Given the winds: bars derived by count, x is direction.”

Read the two ends. North is at the far left and north-west at the far right, with the whole compass between them, when the two directions are 45 degrees apart in the world. The chart has cut the circle open and flattened it, and the cut is invisible: nothing in the picture says that the two ends belong together. A reader who did not already know the compass could not recover it from this.

Now add one atom to the same sentence:

data(winds) + bar * count + x(direction) + polar()
data(winds) + bar * count + x(col.direction) + polar()
data(winds) + bar * count + x(:direction) + polar()
plot(data(winds), layer(bar, count), x(col.direction), polar())
N NE E SE S SW W NW 20 40 60 Count Direction

“Given the winds: bars derived by count, x is direction, in polar.”

Same mark, same transform, same binding, same data. The only thing that changed is the space the plot is drawn in, and north-west is beside north because on the compass it is beside north. The common west wind is now a long wedge pointing west, rather than a tall bar you find by reading the labels.

This is what a coordinate space is for, and it is why a pie chart, a rose and a radar plot are not chart types in this grammar. They are marks you already know, read in a circle.

33.2 What polar() changes, and what it leaves alone

x becomes the angle and y becomes the radius. That order is Wilkinson’s (Wilkinson, 2005), and his reason is that the first dimension of a plot is its domain: what you search in, which in a circle is a bearing. The second is the range, the amount, which becomes how far out from the center the mark reaches. (That is the rule when both positions are bound. Bind only one and there is nothing to choose, so the one you have becomes the angle: that is the pie, and it has its own section below.)

So x(direction) still says what it always said, that direction is the position each bar stands at. polar() only decides that positions are bearings rather than places along a line. A bar still runs from its baseline to its value, which is why the count still sets how far each wedge reaches: the center is zero.

The next question is whether the rest of the grammar survives the bend: a split by season, say, with the two seasons side by side. Nothing else in the grammar knows the space changed, so nothing else has to be rewritten for it:

data(winds) + bar * count * dodge + x(direction) + color(season) + polar()
data(winds) + bar * count * dodge + x(col.direction) + color(col.season) + polar()
data(winds) + bar * count * dodge + x(:direction) + color(:season) +
  polar()
plot(data(winds), layer(bar, count, dodge), x(col.direction),
  color(col.season), polar())
N NE E SE S SW W NW 10 20 30 Count Direction Season Summer Winter

“Given the winds: bars derived by count and dodge, x is direction, color by season, in polar.”

color splits the count by season, and dodge sets the two halves side by side inside each slot instead of on top of each other. The legend decodes the colors. Every one of those works in a circle exactly as it works on a page, because none of them was ever written in terms of pixels.

play survives the bend too, and it is the one worth naming, because a rose that advances through time asks for nothing new. Each moment of the animation chooses the rows, and the space bends whatever it chose, so neither one changes the other. That chapter’s “Frames in polar” runs a rose and two pies through half a century. That chapter also reads the pie’s full turn as a measure axis, shared across every moment of the sequence.

Change the one word and the seasons pile outward from the center instead, one ring on top of another:

data(winds) + bar * count * stack + x(direction) + color(season) + polar()
data(winds) + bar * count * stack + x(col.direction) + color(col.season) + polar()
data(winds) + bar * count * stack + x(:direction) + color(:season) +
  polar()
plot(data(winds), layer(bar, count, stack), x(col.direction),
  color(col.season), polar())
N NE E SE S SW W NW 20 40 60 Count Direction Season Summer Winter

“Given the winds: bars derived by count and stack, x is direction, color by season, in polar.”

Summer lies against the center and winter is stacked on top of it, so each wedge now reaches the season total. West runs out to 63. The dodged rose above stopped at 38, the taller of its two halves. The radial axis grew to fit, and that is why stack is a transform rather than a drawing option. The scale has to read the totals stack accumulates.

The pair is worth reading together, because each hides what the other shows. Stacked, the compass reads by total wind, and west is plainly the prevailing direction. Dodged, the seasons stand side by side, and you can see that the south-west blows in summer (30 against 17) while the west blows in winter (38 against 25). Neither is the correct rose. The rose is not a chart with its own options at all. It is bar, with its axis bent.

33.3 Where the circle starts

The angular axis begins at the top and runs clockwise, which is how a clock and a compass are both read. The first category sits on the top of the circle rather than beside it. That is why the rose above needed nothing said to it: north points straight up, because north is the first of the eight directions and the axis starts at the top.

The starting angle needed a decision, and the reason is worth knowing. A categorical scale runs from half a slot before the first category to half a slot after the last, so the scale’s own origin is padding rather than any place in the data. A start angle aimed at the padding would put north at 22.5 degrees, leaving you to write -180/8 to correct it. A flat categorical axis already puts its tick at the category’s center, so the circle agrees with it: start points at the category, not at the gap before it.

The day_cycle table holds one day of trips, hour by hour:

day_cycle: first 5 of 25 rows
hour trips
0 31
1 18
2 11
3 8
4 9

The axis starts at the top, so midnight sits there. A working day reads better with noon at the top. polar(start = ) turns the whole space by a number of degrees:

data(day_cycle) + line + x(hour) + y(trips) + polar(start = -180)
data(day_cycle) + line + x(col.hour) + y(col.trips) + polar(start = -180)
data(day_cycle) + line + x(:hour) + y(:trips) + polar(start = -180)
plot(data(day_cycle), line, x(col.hour), y(col.trips),
  polar({ start: -180 }))
0 10 20 50 100 150 Trips Hour

“Given the day cycle: a line, x is hour, y is trips, in polar, starting at -180.”

Those are the same trips, turned half a circle. Noon is at the top now and midnight at the bottom, which is the right way up if you are reading the working day rather than the calendar one. start is a property of the space rather than of the data, the polar counterpart of the space(turn =, tilt =) that sets the angle a 3-D plot is viewed from (see Space). It changes how you look, never what is drawn.

33.4 One turn, whatever the data spans

The angular axis is periodic: one full turn covers exactly the range the axis covers, so its two ends land on the same spoke. That is what closes the circle with no seam and no empty wedge, and it has a consequence worth saying out loud. The circle needs the whole cycle, or the two ends will meet in the wrong place.

Here is a day of trips, hour by hour. The table runs from hour 0 to hour 24 with both endpoints present, because midnight is where the day ends and also where it begins:

data(day_cycle) + line + x(hour) + y(trips) + polar()
data(day_cycle) + line + x(col.hour) + y(col.trips) + polar()
data(day_cycle) + line + x(:hour) + y(:trips) + polar()
plot(data(day_cycle), line, x(col.hour), y(col.trips), polar())
0 10 20 50 100 150 Trips Hour

The curve closes at the top because the reading at 24 is the reading at 0, and the two sit on the same spoke. That is the first of three ways a circle closes. Most periodic data does not include both ends. A tide gauge reads every three hours and reaches neither midnight (hour 0 nor hour 24), so the axis it gets spans 1 to 22, and the failure that causes is easy to see:

tide: first 5 of 8 rows
hour height
1 2.1
4 4.4
7 3.6
10 1.2
13 0.9
data(tide) + line + x(hour) + y(height) + polar()
data(tide) + line + x(col.hour) + y(col.height) + polar()
data(tide) + line + x(:hour) + y(:height) + polar()
plot(data(tide), line, x(col.hour), y(col.height), polar())
10 20 1 2 3 4 Height Hour

One turn covers 1 to 22, so hour 1 and hour 22 land on the same spoke. The first and last readings are drawn on top of each other, three hours apart in the world and zero degrees apart on the page. Every reading between them sits at the wrong angle too, spread over 21 hours instead of 24.

A periodic axis cannot tell that your variable is periodic; nothing in a column of hours says the day wraps. So say it:

data(tide) + line + x(hour, limits = c(0, 24)) + y(height) + polar()
data(tide) + line + x(col.hour, limits = [0, 24]) + y(col.height) + polar()
data(tide) + line + x(:hour, limits = [0, 24]) + y(:height) + polar()
plot(data(tide), line, x(col.hour, { limits: [0, 24] }), y(col.height),
  polar())
0 10 20 1 2 3 4 Height Hour

“Given the tide: a line, x is hour from 0 to 24, y is height, in polar.”

Now each reading sits where a clock would put it. Hour 1 is a twenty-fourth of a turn past midnight, and hour 22 is a twelfth of a turn short of it. The two are 45 degrees apart, which is the three hours they really are.

The curve still does not close, which is the second case, and that is the correct picture. The gauge took no reading at midnight, and limits states a domain; it does not invent an observation. The gap you can see between hour 22 and hour 1 is the three-hour gap in the data, drawn at the size it is. Contrast the trips above, which close because a reading at 24 genuinely exists. A categorical angle is the third case and closes for a third reason, below: there every slot is filled, so the last category is adjacent to the first by construction rather than by luck.

limits states the domain the axis runs over rather than deriving it from the data, which is the general fix and not a polar one: the same words keep a color ramp fixed across facets. Scales has the rest of it. Note what it did not do here either: no reading was dropped, because 1 through 22 all lie inside 0 through 24. Stating a domain widens an axis as readily as it narrows one.

The same cycle filled is area, which closes on the baseline ring rather than on a straight line under the curve:

data(day_cycle) + area + x(hour) + y(trips) + polar()
data(day_cycle) + area + x(col.hour) + y(col.trips) + polar()
data(day_cycle) + area + x(:hour) + y(:trips) + polar()
plot(data(day_cycle), area, x(col.hour), y(col.trips), polar())
0 10 20 50 100 150 Trips Hour

“Given the day cycle: an area, x is hour, y is trips, in polar.”

33.5 The radar: a categorical angle

A named angle closes differently, and the difference is worth following because the data causes it rather than the renderer. An hour axis closes because you gave it both ends of the cycle, 0 and 24, and the scale put them on one spoke. Categories cannot do that: each appears exactly once, by definition, so there is no repeated endpoint to land on. What they have instead is that together they fill the whole turn, which makes the last one genuinely adjacent to the first. So a path across categories closes:

gapminder_2007: first 5 of 142 rows
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) + line * mean + x(continent) + y(life) + polar() +
  title("line * mean + x(continent) + polar()")
(data(gapminder_2007) + line * mean + x(col.continent) + y(col.life) + polar() +
  title("line * mean + x(continent) + polar()"))
data(gapminder_2007) + line * mean + x(:continent) + y(:life) + polar() +
  title("line * mean + x(continent) + polar()")
plot(data(gapminder_2007), layer(line, mean), x(col.continent),
  y(col.life), polar(), title("line * mean + x(continent) + polar()"))
Asia Europe Africa Americas Oceania 60 70 80 line * mean + x(continent) + polar() Life Continent

“Given gapminder 2007: a line derived by mean, x is continent, y is life, in polar.”

That is a radar plot, and there is no radar atom in this grammar: the same sentence flat is the profile from the Line chapter, with a straight axis and therefore two ends. Bending the space is what joins them. Filled, it is the shape people usually mean by the name:

data(gapminder_2007) + area * mean + x(continent) + y(life) + polar() +
  title("area * mean: the filled radar")
(data(gapminder_2007) + area * mean + x(col.continent) + y(col.life) + polar() +
  title("area * mean: the filled radar"))
data(gapminder_2007) + area * mean + x(:continent) + y(:life) + polar() +
  title("area * mean: the filled radar")
plot(data(gapminder_2007), layer(area, mean), x(col.continent),
  y(col.life), polar(), title("area * mean: the filled radar"))
Asia Europe Africa Americas Oceania 20 40 60 80 area * mean: the filled radar Life Continent

Split it and each group is its own outline, and the split reads exactly as it does on a flat profile:

gm_eras: first 5 of 284 rows
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
data(gm_eras) + line * mean + x(continent) + y(life) + color(era) + polar() +
  title("half a century apart, two rings")
(data(gm_eras) + line * mean + x(col.continent) + y(col.life) + color(col.era) + polar() +
  title("half a century apart, two rings"))
data(gm_eras) + line * mean + x(:continent) + y(:life) + color(:era) +
  polar() + title("half a century apart, two rings")
plot(data(gm_eras), layer(line, mean), x(col.continent), y(col.life),
  color(col.era), polar(), title("half a century apart, two rings"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 half a century apart, two rings Life Continent Era 1957 2007

The same criticism applies to the radar as to the rose. A category’s distance from the center is a radius, so its area grows as the square, and the enclosed shape covers an area that no number in the table gives. Use it when the categories really are a closed set with no first or last member, which is the case a straight axis misreports by having to pick an order. When the question is which continent is highest, the flat bar chart of the same sentence answers it precisely, and is two atoms away.

33.6 An angle that is measured, not named

The eight compass points are names. An instrument records the same directions as degrees, with a speed beside each reading. An angular position does not have to be a category. bearing is the same wind observation recorded in degrees, a continuous position that happens to run around a circle:

data(winds) + point + x(bearing) + y(speed) + color(season) + polar()
data(winds) + point + x(col.bearing) + y(col.speed) + color(col.season) + polar()
data(winds) + point + x(:bearing) + y(:speed) + color(:season) + polar()
plot(data(winds), point, x(col.bearing), y(col.speed), color(col.season),
  polar())
100 200 300 10 20 Speed Bearing Season Summer Winter

“Given the winds: points, x is bearing, y is speed, color by season, in polar.”

Each dot sits at its own bearing, at the radius its speed sets. The observations were recorded at eight bearings, and those eight groups read as groups rather than as eight separate columns. The radial gridlines are read outward from the center, so a dot near the rim is a fast wind whichever way it was blowing.

bin cuts a measured angle into wedges the same way it cuts a flat axis into bars:

data(winds) + bar * bin + x(bearing) + polar()
data(winds) + bar * bin + x(col.bearing) + polar()
data(winds) + bar * bin + x(:bearing) + polar()
plot(data(winds), layer(bar, bin), x(col.bearing), polar())
100 200 300 20 40 Count Bearing

“Given the winds: bars derived by bin, x is bearing, in polar.”

That is the rosa ventorum, Latin for wind rose, which cartographers drew by hand from the 13th century. It is one atom different from a histogram: bar * bin in both cases, read on a line or read in a circle. The bins touch here as they touch there, because a histogram cuts a continuous axis into adjacent intervals, and that is true whichever shape the axis has been bent into.

A compass is a cycle, though, and the periodic axis cannot know that, just as with the hours. The range is fitted to what was observed, so the turn is the span of the bins rather than the 360 degrees a bearing actually runs over. The join between the two ends sits a little away from north. Stating the domain fixes it here exactly as it did there:

data(winds) + bar * bin + x(bearing, limits = c(0, 360)) + polar()
data(winds) + bar * bin + x(col.bearing, limits = [0, 360]) + polar()
data(winds) + bar * bin + x(:bearing, limits = [0, 360]) + polar()
plot(data(winds), layer(bar, bin), x(col.bearing, { limits: [0, 360] }),
  polar())
0 100 200 300 20 40 Count Bearing

The wedges now tile the compass rather than the data, so north on the plot is north.

33.7 The pie: where you put the category

Everything so far put the category on a position: x(direction) gave each compass point its own slot, and the count decided how far out the wedge reached. A pie does the opposite. Its categories are not positions at all; they are a split, and what goes around the circle is the measure itself.

That is the whole difference, and the grammar can say it. Take the category off x and give it to color, then say that the pieces lie end to end:

data(winds) + bar * count * stack + color(direction) + polar()
data(winds) + bar * count * stack + color(col.direction) + polar()
data(winds) + bar * count * stack + color(:direction) + polar()
plot(data(winds), layer(bar, count, stack), color(col.direction), polar())
Count Direction N NE E SE S SW W NW

Read the sentence: a bar, counted, stacked, colored by direction, in polar. Not one word of it is about pies. The slices are the segments of a stacked bar, the angles are their shares, and the legend is the key.

The flat form is the same sentence without the last atom, and it is a real chart in its own right, the one column that shows how a total divides:

data(winds) + bar * count * stack + color(direction)
data(winds) + bar * count * stack + color(col.direction)
data(winds) + bar * count * stack + color(:direction)
plot(data(winds), layer(bar, count, stack), color(col.direction))
0 100 200 Count Direction N NE E SE S SW W NW

Those two pictures carry identical numbers. Wilkinson’s second chapter is called How To Make a Pie, and his answer is exactly this: a pie chart is a stacked bar in polar coordinates. The column’s height is the pie’s full turn, and each segment’s share of the height is its slice’s share of the circle.

Nothing new was added to the grammar to reach it. There is no pie() atom, no type = "pie", and no argument anywhere that says which chart to draw. Two things happen on their own. First, a bar divided only by its color split has one slot to stand in, so it needs no position axis, and dropping x is what says so. Second, a plot with one position bound has nothing to choose between: that position becomes the angle, and the radius is left as the constant that sets the pie’s size. Both were in Wilkinson’s book, where the one-argument polar function is a separate tool from the two-argument one.

The measure can be anything a group can be reduced to, not just a count. Swap count for sum, name the column it should total, and the same eight directions are weighted by how hard the wind blew rather than by how often:

data(winds) + bar * sum * stack + y(speed) + color(direction) + polar()
data(winds) + bar * sum * stack + y(col.speed) + color(col.direction) + polar()
data(winds) + bar * sum * stack + y(:speed) + color(:direction) + polar()
plot(data(winds), layer(bar, sum, stack), y(col.speed),
  color(col.direction), polar())
Speed Direction N NE E SE S SW W NW

“Given the winds: bars derived by sum and stack, y is speed, color by direction, in polar.”

Compare it with the pie above: the same categories, a different answer. West is both the most common direction and the strongest, so its slice widens from about a quarter of the circle to a third. Winds from the north and south are the gentlest, so theirs shrink by roughly two fifths, even though exactly the same observations went into them. One atom decided which question the circle answers.

And proportion puts the shares on the axis rather than leaving them implied:

data(winds) + bar * proportion * stack + color(season) + polar()
data(winds) + bar * proportion * stack + color(col.season) + polar()
data(winds) + bar * proportion * stack + color(:season) + polar()
plot(data(winds), layer(bar, proportion, stack), color(col.season),
  polar())
Proportion Season Summer Winter

“Given the winds: bars derived by proportion and stack, color by season, in polar.”

The split has to be there. A stack with nothing to pile, or a bar with neither a position nor a split, is refused rather than guessed at. So is a statistic that only means something along an axis:

data(winds) + bar * bin * stack + color(season) + polar()
data(winds) + bar * bin * stack + color(col.season) + polar()
data(winds) + bar * bin * stack + color(:season) + polar()
plot(data(winds), layer(bar, bin, stack), color(col.season), polar())
Error:
! gog: `bin` describes how values are spread along an axis, and this `bar` has no `x()` to spread them along. Add `x(<column>)` to draw the distribution, or use `count`/`sum` for one value per group.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

33.8 What a circle makes harder to read

The grammar draws every rose in this chapter, and drawing one is not the same as recommending it. A wedge’s area grows with the square of its radius, so a count that doubles does not draw twice as much; it draws four times as much. Wilkinson says it plainly of the rose: the polar bar chart confounds area with radius, and growing the radius as the square root only partly repairs it. A reader who judges these by area, and most readers judge filled shapes by area, will read a stronger west wind than the numbers support.

That is Law 8 working as intended: the engine guarantees a plot is well formed, never that it is well chosen. Use a circle when the variable really is a circle, which is the case a flat axis misreports. Use the flat bar chart when the comparison must be precise.

The pie avoids that criticism and has one of its own. Its slices are angles rather than radii, so a share of the total really is the same share of the circle, and readers judge that share well. What angles are bad at is ranking: two slices of 22% and 19% are hard to order in a circle and trivial to order in a column. Since both come from the same sentence, when ranking is the question, drop the last atom and read the bar.

33.9 The marks in a circle

The rose counted observations per direction. How fast the wind blew from each direction, and how much that varied, is a question for the summary marks. There is no mark that draws on the page and refuses the circle. The summary marks read here exactly as they read flat: a box is still a five-number summary, a whisker still shows how far the values reach, a band is still a spread. Only the geometry changed.

data(winds) + box + x(direction) + y(speed) + polar()
data(winds) + box + x(col.direction) + y(col.speed) + polar()
data(winds) + box + x(:direction) + y(:speed) + polar()
plot(data(winds), box, x(col.direction), y(col.speed), polar())
N NE E SE S SW W NW 10 20 Speed Direction

“Given the winds: boxes, x is direction, y is speed, in polar.”

Each box is a wedge of the ring between its quartiles, its median an arc across it, its whiskers running straight out from the center. Compare it with the same sentence flat and nothing has been added or taken away.

The whisker alone shows less, and it is the one to use when the quartiles are more detail than the question needs:

data(winds) + interval * range + x(direction) + y(speed) + polar()
data(winds) + interval * range + x(col.direction) + y(col.speed) + polar()
data(winds) + interval * range + x(:direction) + y(:speed) + polar()
plot(data(winds), layer(interval, range), x(col.direction), y(col.speed),
  polar())
N NE E SE S SW W NW 10 20 Speed Direction

“Given the winds: intervals derived by range, x is direction, y is speed, in polar.”

A ribbon works the same way. Give each compass point a low and a high value, and polar() turns the ribbon into a radar band, the filled counterpart of the radar that line drew.

data(winds) + ribbon * range + x(direction) + y(speed) + polar()
data(winds) + ribbon * range + x(col.direction) + y(col.speed) + polar()
data(winds) + ribbon * range + x(:direction) + y(:speed) + polar()
plot(data(winds), layer(ribbon, range), x(col.direction), y(col.speed),
  polar())
N NE E SE S SW W NW 10 20 Speed Direction

“Given the winds: a ribbon derived by range, x is direction, y is speed, in polar.”

A staircase holds each value across its own slot rather than sloping between them, so in a circle it is a ring of arcs joined by radial jumps. And because the categories exhaust the turn, the last one’s tread carries around to the first and the shape closes:

data(winds) + step * mean + x(direction) + y(speed) + polar()
data(winds) + step * mean + x(col.direction) + y(col.speed) + polar()
data(winds) + step * mean + x(:direction) + y(:speed) + polar()
plot(data(winds), layer(step, mean), x(col.direction), y(col.speed),
  polar())
N NE E SE S SW W NW 10 15 20 Speed Direction

“Given the winds: a step outline derived by mean, x is direction, y is speed, in polar.”

Look at where the value changes. Every jump is a straight line out from the center, and every held value is a genuine arc rather than a chord cutting across it. That distinction is the whole of what these marks needed. A segment that holds a value across a span has to follow the ring. A straight line between the same two points falls inside the circle, and every point along it would be at a radius the data never took.

A region is a wedge, so a zone shades one. Two categorical axes give the tile plot bent into a disc, a cell per pair:

data(winds) + zone * count + x(direction) + y(season) + polar()
data(winds) + zone * count + x(col.direction) + y(col.season) + polar()
data(winds) + zone * count + x(:direction) + y(:season) + polar()
plot(data(winds), layer(zone, count), x(col.direction), y(col.season),
  polar())
N NE E SE S SW W NW Winter Summer Season Direction Count 38.00 22.00 6.00

“Given the winds: zones derived by count, x is direction, y is season, in polar.”

The one thing that does not bend is surface, and the circle is not what refuses it. A sheet through three positions has no reading in a space with two, which is why it does not draw on a flat page either. A plot is also drawn in one space and not two, so asking for polar() and z() together is refused rather than one of the two being ignored.

One sentence is refused here, and the reason is worth reading, because it is the third form of one rule:

gm_all: first 5 of 1704 rows
country continent year life population gdp
Afghanistan Asia 1952 28.801 8425333 779.4453
Afghanistan Asia 1957 30.332 9240934 820.8530
Afghanistan Asia 1962 31.997 10267083 853.1007
Afghanistan Asia 1967 34.020 11537966 836.1971
Afghanistan Asia 1972 36.088 13079460 739.9811
data(gm_all) + zone * bin(tiling = "hex") + x(gdp) + y(life) + polar()
data(gm_all) + zone * bin(tiling = "hex") + x(col.gdp) + y(col.life) + polar()
data(gm_all) + zone * bin(tiling = "hex") + x(:gdp) + y(:life) + polar()
plot(data(gm_all), layer(zone, bin({ tiling: "hex" })), x(col.gdp),
  y(col.life), polar())
Error:
! gog: `bin(tiling = "hex")` partitions a *plane*, and `polar()` bends the plane into a circle — where a cell is a sector, and a hexagon's six equal sides are equal against a distance the space no longer has (a step of angle is longer at the rim than at the center). Drop the tiling for `rect`, whose cells are the sectors a bent rectangle already is, or drop `polar()` to cut a flat plane.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

bin(tiling = ) says how to partition a plane. It is refused on a one-dimensional bin because an interval is not a plane, and on a categorical axis because slots are an order rather than a distance. Here it is refused for the third form of the same problem. A bent plane has no distance for a hexagon to be regular against, because a step of angle at the rim is longer than the same step near the center. A hexagon exists to keep every cell the same size and shape, and a bent plane takes that away. A rectangle survives the bend, because a bent rectangle is a sector and that is a shape the engine draws.

Which marks draw in which coordinate space is stated in full, on one page, by the Mark × Space grid in Combinations. It is generated from the engine’s own table, so it cannot promise a plot the engine will not draw.

33.10 The sunburst: depth is the radius

Where does a household’s money go, and inside each group, which items take the most? That is a tree of shares, one level inside another. A sunburst draws a tree: one ring per level, each arc as wide as its share of the whole and sitting inside its parent’s. It has no mark of its own and needs none. An arc of a ring is a rectangle in angle and radius. zone is the mark bounded by a pair on each axis, and this space is what bends a rectangle into a sector. The arithmetic between a hierarchy and those four edges is partition.

The hierarchy arrives as columns. spending is a household budget with one row per leaf, meaning the last level of a branch, and the first three columns spell the path down to it:

spending: first 6 of 12 rows
group item detail amount
Housing Rent NA 980
Housing Utilities Energy 140
Housing Utilities Water 35
Housing Repairs NA 120
Food Groceries NA 420
Food Eating out NA 185

A missing value is not missing data there. It says the branch stops: rent has no sub-parts worth naming, so it reaches the second level and no further.

data(spending) + zone * partition(group, item, detail) + x(amount) +
  color(group) + polar()
(data(spending) + zone * partition(col.group, col.item, col.detail) + x(col.amount) +
  color(col.group) + polar())
data(spending) + zone * partition(:group, :item, :detail) + x(:amount) +
  color(:group) + polar()
plot(data(spending),
  layer(zone, partition(col.group, col.item, col.detail)), x(col.amount),
  color(col.group), polar())
500 1000 1500 2000 Amount Group Housing Food Transport Leisure

“Given the spending table: zones derived by partition through group, item and detail, x is amount, color by group, in polar.”

partition(group, item, detail) names the levels from the top of the tree down, so group is the innermost ring and detail the rim. x(amount) is what each branch is weighed by. Nothing in the sentence is about drawing, and nothing in it is about circles.

Bind nothing to x and every leaf weighs 1, which is the tally count already does when nothing else is measured. The rings then divide by how many leaves each branch has rather than by what they cost:

data(spending) + zone * partition(group, item, detail) + color(group) + polar()
data(spending) + zone * partition(col.group, col.item, col.detail) + color(col.group) + polar()
data(spending) + zone * partition(:group, :item, :detail) +
  color(:group) + polar()
plot(data(spending),
  layer(zone, partition(col.group, col.item, col.detail)),
  color(col.group), polar())
5 10 Group Housing Food Transport Leisure

Housing fills half the circle above and a third of it here, because it is 1,275 of the 2,490 spent and 4 of the 12 leaves that spend it. That is the same swap the pie made between count and sum, arriving at a tree.

Read the outer rim. It is solid above Utilities and Car and blank above every other item, because those two are the only items with a third level. A ragged rim is what a real hierarchy looks like, and it comes from the missing values rather than from anything the plot was told.

The radial axis carries depth, which is the level a node sits at: 1 for the groups on the inner ring, 2 for the items outside them, 3 for the details on the rim. The transform computes it and gives it that name. It is a column you can bind like any other, even though it is not in your table: partition is what put it there. Naming it is how you say what the radius runs over, and stating a domain that starts below the first ring is what hollows out the middle:

data(spending) + zone * partition(group, item, detail) + x(amount) +
  y(depth, limits = c(0, 4)) + color(group) + polar()
(data(spending) + zone * partition(col.group, col.item, col.detail) + x(col.amount) +
  y(col.depth, limits = [0, 4]) + color(col.group) + polar())
data(spending) + zone * partition(:group, :item, :detail) + x(:amount) +
  y(:depth, limits = [0, 4]) + color(:group) + polar()
plot(data(spending),
  layer(zone, partition(col.group, col.item, col.detail)), x(col.amount),
  y(col.depth, { limits: [0, 4] }), color(col.group), polar())
500 1000 1500 2000 Amount Group Housing Food Transport Leisure

That is worth a sentence, because it is not a setting. A hole is a stretch of the radial axis with nothing standing on it: the innermost ring starts at 1, and saying the axis runs from 0 leaves the first unit of radius empty. Widen the stretch and the hole widens with it, since limits states a domain and the rings keep the places they always had: c(-1, 4) gives up two fifths of the radius rather than a quarter.

33.11 The donut: one level is still a tree

A hierarchy one level deep is still a hierarchy, and everything above is true of it as well. One level is one ring, that ring is at depth 1, and an axis running from 0 to 2 leaves the first of its two units empty, which is a donut:

data(spending) + zone * partition(group) + x(amount) +
  y(depth, limits = c(0, 2)) + color(group) + polar()
(data(spending) + zone * partition(col.group) + x(col.amount) +
  y(col.depth, limits = [0, 2]) + color(col.group) + polar())
data(spending) + zone * partition(:group) + x(:amount) +
  y(:depth, limits = [0, 2]) + color(:group) + polar()
plot(data(spending), layer(zone, partition(col.group)), x(col.amount),
  y(col.depth, { limits: [0, 2] }), color(col.group), polar())
1000 1500 2000 Amount Group Housing Food Transport Leisure

There is no donut atom, no hole = setting, and nothing in that sentence about donuts. It is the sunburst’s sentence with one level named instead of three, drawn on a radial axis with one empty unit at the bottom of it.

The donut you usually see published writes its names in the ring, and that is the text layer from Naming the nodes (just below) with one level instead of two:

data(spending) + x(amount) + y(depth, limits = c(0, 2)) +
  zone * partition(group) + color(group) +
  text * partition(group) + label(name) + style(color = "white") +
  polar() + x_label("")
(data(spending) + x(col.amount) + y(col.depth, limits = [0, 2]) +
  zone * partition(col.group) + color(col.group) +
  text * partition(col.group) + label(col.name) + style(color = "white") +
  polar() + x_label(""))
data(spending) + x(:amount) + y(:depth, limits = [0, 2]) +
  zone * partition(:group) + color(:group) + text * partition(:group) +
  label(:name) + style(color = "white") + polar() + x_label("")
plot(data(spending), x(col.amount), y(col.depth, { limits: [0, 2] }),
  layer(zone, partition(col.group)), color(col.group),
  layer(text, partition(col.group)), label(col.name),
  style({ color: "white" }), polar(), x_label(""))
Housing Food Transport Leisure 1000 1500 2000 Group Housing Food Transport Leisure

“Given the spending table: x is amount, y is depth from 0 to 2, zones derived by partition through group, color by group, and also text derived by partition through group, label by name, in polar.”

The legend now says twice what the ring says once, so a published plot would keep one of them. Both are kept here, so that the two ways of decoding a wedge stand side by side.

The pie is the one plot in this space that cannot have a hole, and that follows from what a pie binds. A pie puts its measurement on the angle and leaves the radius constant, so it has no radial domain to widen. A hole here would have to be a number given to the space, and a hole is never a number. The sentence that wants a hole is the sentence whose radius carries something, and that sentence is one atom away.

33.12 The same sentence, unbent

If the circle really is one word, taking the word away should leave a readable plot. Take polar() off the three-level sunburst and change nothing else:

data(spending) + zone * partition(group, item, detail) + x(amount) +
  y(depth, limits = c(0, 4)) + color(group)
(data(spending) + zone * partition(col.group, col.item, col.detail) + x(col.amount) +
  y(col.depth, limits = [0, 4]) + color(col.group))
data(spending) + zone * partition(:group, :item, :detail) + x(:amount) +
  y(:depth, limits = [0, 4]) + color(:group)
plot(data(spending),
  layer(zone, partition(col.group, col.item, col.detail)), x(col.amount),
  y(col.depth, { limits: [0, 4] }), color(col.group))
500 1000 1500 2000 Amount Group Housing Food Transport Leisure

That is an icicle chart: depth going up instead of outward, share going across instead of around. The two are one coordinate space apart, and that is this chapter’s point arriving at a chart with a name of its own. A rose and a bar chart are the same pair, and so are a pie and a stacked bar.

The empty band along the bottom is the hole, and seeing it flat is the clearest statement of what a hole is. It is not a round thing. It is a stretch of the depth axis with nothing on it, and bending the picture wraps that stretch into a disc at the center.

33.13 Naming the nodes

The legend decodes the groups and nothing inside them, so the items on the middle ring are unnamed. partition publishes each node’s center as well as its four edges, which is why a second mark can read the same computation. text takes the center and labels the node:

data(spending) + x(amount) + y(depth, limits = c(0, 4)) +
  zone * partition(group, item, detail) + color(group) +
  text * partition(group, item) + label(name) + style(color = "white") +
  polar() + x_label("")
(data(spending) + x(col.amount) + y(col.depth, limits = [0, 4]) +
  zone * partition(col.group, col.item, col.detail) + color(col.group) +
  text * partition(col.group, col.item) + label(col.name) + style(color = "white") +
  polar() + x_label(""))
data(spending) + x(:amount) + y(:depth, limits = [0, 4]) +
  zone * partition(:group, :item, :detail) + color(:group) +
  text * partition(:group, :item) + label(:name) +
  style(color = "white") + polar() + x_label("")
plot(data(spending), x(col.amount), y(col.depth, { limits: [0, 4] }),
  layer(zone, partition(col.group, col.item, col.detail)),
  color(col.group), layer(text, partition(col.group, col.item)),
  label(col.name), style({ color: "white" }), polar(), x_label(""))
Housing Food Transport Leisure Rent Utilities Repairs Groceries Eating out Car Season ticket Holidays Hobbies Subscriptions 500 1000 1500 2000 Group Housing Food Transport Leisure

label(name) reads a column the transform wrote, the way color(count) reads a binned tally. And the text layer names two levels where the zone names three, which is how the rim is left unlabeled. A shallower partition of the same table puts its nodes in exactly the same places, because the arcs are decided by the leaves underneath them, and those did not change.

One thing about that plot is a limit of the engine rather than a choice. The names sit upright where a published sunburst runs them along their arcs, because text places a label at a point in this space and has no rotation to follow one with.

There is an x_label("") in that sentence and no y_label("") beside it, and the asymmetry is the whole of how these plots are labeled. The radial axis carries the depth the transform computed, and nobody reads a level off a scale, so it draws no ticks, no rings and no name of its own. That is decided from what the axis carries rather than asked for, the way a pie is recognized from its bindings. The measure keeps its guide, because an amount around a circle is a quantity in the data’s own units, and the empty string is what removes that label.

33.14 What a tree lets the grammar check

A hierarchy has a rule that rectangles do not: every child lies inside its parent, and children of one parent do not overlap. Handed the four numbers directly the engine cannot know that, and will draw a wrong sunburst and say nothing. Handed the tree, it can, and one case is worth meeting because it is a real ambiguity rather than a mistake:

mixed <- data.frame(group = c("Housing", "Housing"),
                    item  = c(NA, "Rent"),
                    amount = c(50, 980))
data(mixed) + x(amount) + zone * partition(group, item) + polar()
mixed = {
  "group": ["Housing", "Housing"],
  "item": [None, "Rent"],
  "amount": [50, 980],
}
data(mixed) + x(col.amount) + zone * partition(col.group, col.item) + polar()
mixed = (
  group = ["Housing", "Housing"],
  item = [missing, "Rent"],
  amount = [50, 980],
)
data(mixed) + x(:amount) + zone * partition(:group, :item) + polar()
const mixed = {
  group: ["Housing", "Housing"],
  item: [null, "Rent"],
  amount: [50, 980],
};
plot(data(mixed), x(col.amount),
  layer(zone, partition(col.group, col.item)), polar())
Error:
! gog: `Housing` has a value of its own *and* children with values, and those two readings draw different pictures: either its arc is the children's total (its own number already counted among them), or its own number sits beside them and widens it. gog will not pick — the arithmetic is the accounts', not the grammar's. Put every number on a **leaf** and let the parents be the sums, which is what a partition computes.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

Housing has a number of its own and a child with a number, and those two readings draw different pictures: either its arc is the child’s total, or its own 50 sits beside the 980 and widens it. Plotly answers it with a setting called branchvalues. gog does not choose for you, because that arithmetic belongs to your table rather than to the grammar. Put every number on a leaf and let the parents be the sums, which is what a partition computes.

The other refusals are the same rule in smaller cases. A level must be a name that a branch shares, so a continuous column is refused and sent to x(), where a measurement belongs. A branch may stop early but may not skip a level, since nothing below a stop can be reached. And a mark with no reading for a region is sent to zone or text.

One shape of input stays outside this atom on purpose. A hierarchy written as columns, one level or several, is what partition reads and what faceting reads too. A tree of arbitrary depth arriving as a parent column and a child column is the shape a network has, and Network is where that shape is read: layout(parent, child) places it, in a space with its own rules. Each family answers the question it is built for.