| 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 |
22 Scales
What do you do when most of your data is crowded into one corner? You change the scale, which decides how a number becomes a position. It is written on the binding, not as an atom of its own:
x(gdp, scale = "log")Scales are a property of the axis, so they live where the axis is bound. There is no scale() atom to write separately. That would make you name the channel twice, once to bind it and once to scale it, and the two could then disagree.
Writing the scale on the binding helps a second way, and that way is about remembering rather than being correct. The scale sits inside the thing it measures, so there is only one place to look. A reader who wants to know how gdp is being read finds the answer in x(gdp, scale = "log") itself. Nothing else in the sentence can change it, and there is no second function to look up.
22.1 A scale is not a transform
You could also take the logarithm of the column yourself and plot the result. That is a different act, and the difference decides what the axis says.
bin and mean are transforms: they read your data and produce different data. A scale produces no new values at all. It changes how far along the axis a number lands and what the ticks say, and nothing else.
The difference is visible in the finished plot. Here is gdp plotted with a log scale:
data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
color(continent) +
x_label("GDP per capita") +
title("Income and life expectancy: log scale")(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
color(col.continent) +
x_label("GDP per capita") +
title("Income and life expectancy: log scale"))data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
color(:continent) + x_label("GDP per capita") +
title("Income and life expectancy: log scale")plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
y(col.life), color(col.continent), x_label("GDP per capita"),
title("Income and life expectancy: log scale"))“Given gapminder 2007: points, x is gdp on a log scale, y is life, color by continent.”
The axis reads 1K and 10K. Those are dollars, the units you supplied.
The word log appears nowhere on the plot, and that is deliberate. The units did not change, so the axis name does not claim they did. The ticks are the only sign: each is a whole power of ten, so equal steps along the axis mean equal ratios. Write x_label("GDP per capita (log scale)") when a reader needs it in words. The axis name is yours, and gog never writes the scale into it.
Now the same picture drawn by logging the column yourself:
gm_logged <- gapminder_2007
gm_logged$log_gdp <- log10(gm_logged$gdp)
data(gm_logged) + point + x(log_gdp) + y(life) +
color(continent) +
title("The same shape, with the axis in log units")Identical shape, and the axis now reads 3 and 4. To know a country’s income, the reader has to raise ten to that power. That arithmetic is the work a scale exists to do for them.
So the choice between the two is about what the quantity is. Log the column when the logarithm is itself the quantity of interest: a log-odds, a decibel, a pH. Use a log scale when the quantity is still dollars or people, and you only want to see it across orders of magnitude.
22.2 The order of a scale and a transform
Once a transform is in the plot, a scale is no longer only about display, because a transform has to choose which numbers to work on. gog follows one rule, and it has no per-transform exceptions:
A transform groups by one axis and measures on the other. The scale is applied before the transform on the grouping axis, and after it on the measured axis.
22.2.1 Scaling the grouping axis
A histogram of income is the first place the rule matters. Its bins have to be cut somewhere, in dollars or in their logarithms.
bin groups by x, so a log x is applied first. The bins are cut on the logarithms, not on the dollars. Each bin then spans one constant ratio, so the bars land at a constant spacing and cover the axis:
data(gapminder_2007) + bar * bin + x(gdp, scale = "log") +
x_label("GDP per capita") +
title("Bins cut in log space are evenly spaced")(data(gapminder_2007) + bar * bin + x(col.gdp, scale = "log") +
x_label("GDP per capita") +
title("Bins cut in log space are evenly spaced"))data(gapminder_2007) + bar * bin + x(:gdp, scale = "log") +
x_label("GDP per capita") +
title("Bins cut in log space are evenly spaced")plot(data(gapminder_2007), layer(bar, bin), x(col.gdp, { scale: "log" }),
x_label("GDP per capita"),
title("Bins cut in log space are evenly spaced"))“Given gapminder 2007: bars derived by bin, x is gdp on a log scale.”
Suppose the bins had been cut in dollars and only the drawing had been logarithmic. The first bin alone would cover most of the axis, and the rest would crowd into the right edge. That reads as a broken picture rather than as a modeling choice, which is why the grouping axis is scaled first.
22.2.2 The measured value keeps its own units
Put the log on the measured axis instead and a different question arises. Does a bar on a log scale stand for a sum of dollars, or for a sum of logarithms?
sum writes to y, so a log y is applied after the sum. The two bars below stand for 100 and 10:
| store | sales |
|---|---|
| North | 10 |
| North | 90 |
| South | 1 |
| South | 9 |
data(receipts) + bar * sum + x(store) + y(sales, scale = "log") +
title("A sum is still a sum")(data(receipts) + bar * sum + x(col.store) + y(col.sales, scale = "log") +
title("A sum is still a sum"))data(receipts) + bar * sum + x(:store) + y(:sales, scale = "log") +
title("A sum is still a sum")plot(data(receipts), layer(bar, sum), x(col.store),
y(col.sales, { scale: "log" }), title("A sum is still a sum"))“Given the receipts: bars derived by sum, x is store, y is sales on a log scale.”
If the scale had been applied first, gog would have added the logarithms, giving the log of a product, which is not a quantity anyone asked for. Summing first and displaying second keeps sum meaning what its name says.
The order is worth knowing if you have used ggplot2, which does the opposite. Under scale_y_log10(), a stat_summary(fun = mean) averages the logged values. What comes back is a geometric mean, the average computed on the logs. gog computes the summary in your data’s units and scales the result, so the number the bar stands for is the number you would get from sum() or mean() yourself.
22.2.3 smooth fits inside its groups
The same question arises from the grouping side: is the curve fitted to dollars, or to their logarithms? On incomes that run from a few hundred dollars to nearly fifty thousand, the answer changes the shape of the curve.
smooth groups by x, so a log x fits the curve against log income. That is what straightens a relationship otherwise crowded into the left of the panel:
data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
style(color = "lightgray") +
line * smooth + x(gdp, scale = "log") + y(life) +
x_label("GDP per capita") +
title("A trend fitted against log income")(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
style(color = "lightgray") +
line * smooth + x(col.gdp, scale = "log") + y(col.life) +
x_label("GDP per capita") +
title("A trend fitted against log income"))data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
style(color = "lightgray") + line * smooth + x(:gdp, scale = "log") +
y(:life) + x_label("GDP per capita") +
title("A trend fitted against log income")plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
y(col.life), style({ color: "lightgray" }), layer(line, smooth),
x(col.gdp, { scale: "log" }), y(col.life), x_label("GDP per capita"),
title("A trend fitted against log income"))“Given gapminder 2007: points, x is gdp on a log scale, y is life, and also a line derived by smooth, x is gdp on a log scale, y is life.”
22.3 What a log scale refuses
Some columns cannot take a log scale, and you will meet them: a depth that goes below zero, a balance that went negative, a count of zero.
A logarithm is undefined at zero and below, so those rows have no position on the axis. gog says so rather than dropping them quietly:
| depth | temp |
|---|---|
| 1 | 12 |
| 0 | 14 |
| -4 | 15 |
| 100 | 9 |
data(depth_readings) + point + x(depth, scale = "log") + y(temp)data(depth_readings) + point + x(col.depth, scale = "log") + y(col.temp)data(depth_readings) + point + x(:depth, scale = "log") + y(:temp)plot(data(depth_readings), point, x(col.depth, { scale: "log" }),
y(col.temp))Error:
! gog: `x(depth, scale = "log")` has no place for 2 of 4 rows — a logarithm is undefined at zero and below, and `depth` reaches -4. Filter those rows before plotting, or use a linear scale.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The message names how many rows are affected and the smallest value in the column, because “some of your data cannot be drawn” is not enough to act on.
Text has no logarithm either, and a column of text already gets a categorical axis without being asked:
data(gapminder_2007) + point + x(continent, scale = "log") + y(life)data(gapminder_2007) + point + x(col.continent, scale = "log") + y(col.life)data(gapminder_2007) + point + x(:continent, scale = "log") + y(:life)plot(data(gapminder_2007), point, x(col.continent, { scale: "log" }),
y(col.life))Error:
! gog: `x(continent, scale = "log")` needs a number to take the logarithm of, but `continent` is text. A text column already gets a categorical axis — remove `scale = "log"`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
22.4 Choosing the base
Ten is the usual base for money and for population, and not for everything you measure. A pitch doubles with every octave, and a decay is counted in factors of e.
scale = "log" means base 10 unless you say otherwise:
x(freq, scale = "log", base = 2)The base is a number, not a name. There is no "log2" or "ln" scale, because that would enumerate what one parameter already derives, the same objection that rules out ggplot2’s scale_x_log10 family.
22.4.1 The base changes the labels, not the picture
One result here surprises most people, and it is worth knowing before you set a base. Any two logarithms of the same column differ by a constant factor, and gog normalizes every axis by its own range. The factor cancels, so the points land in the same places in every base. The transforms are unaffected too: bins of equal width in log₂ space are equal width in log₁₀ space, so bar * bin cuts at the same values whichever base you name.
What the base actually chooses is where the gridlines fall and how they read. Here are six frequencies, each one an octave above the last, on base 10 and then base 2:
| freq | level |
|---|---|
| 55 | 3 |
| 110 | 6 |
| 220 | 9 |
| 440 | 7 |
| 880 | 4 |
data(octaves) + point + x(freq, scale = "log") + y(level) +
x_label("Frequency (Hz)") + title("Base 10: gridlines at 100, 1K")(data(octaves) + point + x(col.freq, scale = "log") + y(col.level) +
x_label("Frequency (Hz)") + title("Base 10: gridlines at 100, 1K"))data(octaves) + point + x(:freq, scale = "log") + y(:level) +
x_label("Frequency (Hz)") + title("Base 10: gridlines at 100, 1K")plot(data(octaves), point, x(col.freq, { scale: "log" }), y(col.level),
x_label("Frequency (Hz)"), title("Base 10: gridlines at 100, 1K"))data(octaves) + point + x(freq, scale = "log", base = 2) + y(level) +
x_label("Frequency (Hz)") + title("Base 2: gridlines on every octave")(data(octaves) + point + x(col.freq, scale = "log", base = 2) + y(col.level) +
x_label("Frequency (Hz)") + title("Base 2: gridlines on every octave"))data(octaves) + point + x(:freq, scale = "log", base = 2) + y(:level) +
x_label("Frequency (Hz)") + title("Base 2: gridlines on every octave")plot(data(octaves), point, x(col.freq, { scale: "log", base: 2 }),
y(col.level), x_label("Frequency (Hz)"),
title("Base 2: gridlines on every octave"))“Given the octaves: points, x is freq on a log scale in base 2, y is level.”
The points have not moved. The second plot puts a gridline on each doubling, which is what “an octave” means, so for pitch, bits, or doubling times, base 2 is the one whose labels name what you are counting.
22.4.2 Natural log
The natural base is for a quantity that decays at a constant rate. The amount below falls by a factor of e every hour, so one gridline falls between every two consecutive readings.
R has no e constant, so it is base = exp(1):
| hours | amount |
|---|---|
| 0 | 100.000000 |
| 1 | 36.787944 |
| 2 | 13.533528 |
| 3 | 4.978707 |
| 4 | 1.831564 |
data(decay) + point + x(amount, scale = "log", base = exp(1)) + y(hours) +
x_label("Amount remaining") +
title("Gridlines at each e-folding: one step is a factor of e")(data(decay) + point + x(col.amount, scale = "log", base = math.e) + y(col.hours) +
x_label("Amount remaining") +
title("Gridlines at each e-folding: one step is a factor of e"))data(decay) + point + x(:amount, scale = "log", base = ℯ) + y(:hours) +
x_label("Amount remaining") +
title("Gridlines at each e-folding: one step is a factor of e")plot(data(decay), point, x(col.amount, { scale: "log", base: Math.E }),
y(col.hours), x_label("Amount remaining"),
title("Gridlines at each e-folding: one step is a factor of e"))Notice the labels: e, e², e³, not 2.718, 7.389, 20.09. That follows from one rule gog applies to every base:
Label the quantity when it is a short number; otherwise label the power.
Base 10 gives 1, 10, 100, 1K, always short. Base 2 gives 1, 2, 4 … 1048576, short until the number grows too wide to sit under a tick, and then 2²⁴. Base e has no short quantity above 1, so every tick past the first reads as a power, which is what a reader counting e-foldings wants. If instead you want the axis to read 0, 1, 2, 3 in ln units, you do not want a log scale at all. You want log() applied to the column, and an axis honestly labeled in log units, as at the top of this chapter. A scale keeps the reader’s units; a logged column changes them.
A base only means something as the base of a logarithm, so gog refuses one that has no log scale to belong to:
data(octaves) + point + x(freq, base = 2) + y(level)data(octaves) + point + x(col.freq, base = 2) + y(col.level)data(octaves) + point + x(:freq, base = 2) + y(:level)plot(data(octaves), point, x(col.freq, { base: 2 }), y(col.level))Error:
! gog: `x(freq, base = …)` has no scale to be the base of. A base belongs to a logarithm — add `scale = "log"`, or remove the base.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
22.5 Time: a date column gets a calendar axis
A column of dates raises a question the other columns do not. Where do the ticks go, and what do they say, when the units are years and months rather than tens and hundreds?
The time scale is one you never have to write. A Date or POSIXct column is temporal, the type already says so, and gog reads the calendar from the type, the same way a column of text already gets a categorical axis:
| day | sales |
|---|---|
| 1994-06-30 | 52 |
| 1995-06-30 | 57 |
| 1996-06-30 | 55 |
| 1997-06-30 | 61 |
| 1998-06-30 | 64 |
data(revenue) + line + x(day) + y(sales) +
title("Twenty-six years, ticked in years")(data(revenue) + line + x(col.day) + y(col.sales) +
title("Twenty-six years, ticked in years"))data(revenue) + line + x(:day) + y(:sales) +
title("Twenty-six years, ticked in years")plot(data(revenue), line, x(col.day), y(col.sales),
title("Twenty-six years, ticked in years"))No scale = "time" anywhere: writing it is allowed and means nothing extra, exactly like writing scale = "linear" on a number.
The ticks land on calendar boundaries, because the calendar is not decimal. An axis of years steps 1, 2 or 5 like any number line. Below the year those steps mean nothing: nobody reads an axis cut in fifths of a year. Months step by 1, 2, 3 or 6, so January stays a gridline and 3-month steps read as quarters. Weeks land on Mondays, and days, hours and minutes take the steps a clock face suggests.
| day | orders | weekday | week |
|---|---|---|---|
| 2024-03-01 | 20 | Fri | Week 1 |
| 2024-03-02 | 23 | Sat | Week 1 |
| 2024-03-03 | 25 | Sun | Week 1 |
| 2024-03-04 | 28 | Mon | Week 1 |
| 2024-03-05 | 30 | Tue | Week 1 |
# six_weeks is the cast's daily orders table, see the Preface
data(six_weeks) + line + x(day) + y(orders) +
title("Six weeks, ticked on Mondays")(data(six_weeks) + line + x(col.day) + y(col.orders) +
title("Six weeks, ticked on Mondays"))data(six_weeks) + line + x(:day) + y(:orders) +
title("Six weeks, ticked on Mondays")plot(data(six_weeks), line, x(col.day), y(col.orders),
title("Six weeks, ticked on Mondays"))A tick is labeled at its own resolution: 2010 for a year step, Jan 2024 for a month step, Mar 4 for a day step. A Date column never gets clock ticks, however narrow its range; a POSIXct may:
| at | load |
|---|---|
| 2024-03-04 06:00:00 | 40 |
| 2024-03-04 07:00:00 | 47 |
| 2024-03-04 08:00:00 | 54 |
| 2024-03-04 09:00:00 | 60 |
| 2024-03-04 10:00:00 | 65 |
data(monitoring) + line + x(at) + y(load) +
title("A day and a half, ticked by the clock")(data(monitoring) + line + x(col.at) + y(col.load) +
title("A day and a half, ticked by the clock"))data(monitoring) + line + x(:at) + y(:load) +
title("A day and a half, ticked by the clock")plot(data(monitoring), line, x(col.at), y(col.load),
title("A day and a half, ticked by the clock"))00:00 happens every day, so it names no particular one. The midnight tick shows the date instead. One rule covers every label on a time axis: a tick names the finest unit that distinguishes it from its neighbors.
22.5.1 Bars sit on dates; they never measure one
Daily orders make a natural bar chart, one bar per day, and forty-two bars raise the question of where the ticks go.
Bars positioned at dates are an ordinary time series, and the ticks still come from the calendar rather than one per bar. Wilkinson’s stock-price example ticks Sundays, not trades (Wilkinson, 2005), and the same rule here ticks the six weeks of orders at week boundaries:
data(six_weeks) + bar + x(day) + y(orders) +
title("Daily bars, weekly ticks")(data(six_weeks) + bar + x(col.day) + y(col.orders) +
title("Daily bars, weekly ticks"))data(six_weeks) + bar + x(:day) + y(:orders) +
title("Daily bars, weekly ticks")plot(data(six_weeks), bar, x(col.day), y(col.orders),
title("Daily bars, weekly ticks"))The other direction is refused. A bar’s length is an amount, and a moment in time is not an amount. A bar “reaching” 2007 would be measured from 1970, an origin nobody chose:
data(revenue) + bar + x(sales) + y(day)data(revenue) + bar + x(col.sales) + y(col.day)data(revenue) + bar + x(:sales) + y(:day)plot(data(revenue), bar, x(col.sales), y(col.day))Error:
! gog: `bar` measures its length along `day`, but that is a date column — a bar's length is an amount, and a moment in time is not an amount. Put the date on `x()` and measure a number, or use `point`/`line` for values that are dates.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
22.5.2 What a time axis refuses
A date is stored as a number underneath. You might therefore expect a date to take a log scale, and a number to take a time scale. Neither is allowed.
A moment has no logarithm. The calendar’s zero is an arbitrary convention, and a logarithm measured from an arbitrary zero measures nothing:
data(revenue) + line + x(day, scale = "log") + y(sales)data(revenue) + line + x(col.day, scale = "log") + y(col.sales)data(revenue) + line + x(:day, scale = "log") + y(:sales)plot(data(revenue), line, x(col.day, { scale: "log" }), y(col.sales))Error:
! gog: `x(day, scale = "log")` — `day` is a date column, and a moment in time has no logarithm: the calendar's zero is an arbitrary origin. Log the measured axis instead, or remove the scale.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The reading works in one direction only. A date column is temporal by type, so a plain number cannot be declared temporal at the binding. The engine cannot know whether a plain number like 20656 counts days, seconds, or years:
data(revenue) + point + x(sales, scale = "time") + y(sales)data(revenue) + point + x(col.sales, scale = "time") + y(col.sales)data(revenue) + point + x(:sales, scale = "time") + y(:sales)plot(data(revenue), point, x(col.sales, { scale: "time" }), y(col.sales))Error:
! gog: `x(sales, scale = "time")` — `sales` is not a date column, and a number alone does not say what moment it is. Convert it with `as.Date()` (or `as.POSIXct()`); gog reads the calendar from the column's type.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The fix belongs in the data, where the answer is known: convert the column with as.Date() or as.POSIXct().
Timezones are never converted. The engine draws the clock time you gave it. A POSIXct is formatted in its own timezone when it is drawn, so the axis can never disagree with what print() showed you. If two series must share an axis, put them in one timezone before plotting; that decision belongs to the analysis, not the engine.
22.5.3 Time on the other channels
A scale answers how far along? on every channel that measures, and a date column is no exception: how recent a row is, shown as a color ramp, is a normal question. A legend row stands alone, with no neighboring ticks to give it context, so it carries the full date:
data(six_weeks) + point + x(day) + y(orders) + color(day) +
title("Recent days, darker")(data(six_weeks) + point + x(col.day) + y(col.orders) + color(col.day) +
title("Recent days, darker"))data(six_weeks) + point + x(:day) + y(:orders) + color(:day) +
title("Recent days, darker")plot(data(six_weeks), point, x(col.day), y(col.orders), color(col.day),
title("Recent days, darker"))22.6 The scales
| Scale | What it does |
|---|---|
linear |
Equal steps for equal differences. The default |
log |
Equal steps for equal ratios, on x, y, color, size and opacity, with any base above 1 |
time |
Positions dates and times, chosen from the column type: Date and POSIXct columns, on every channel that measures |
category |
One slot per distinct value, chosen from the column type: columns of text, and columns with a declared order |
22.7 The axis fits your data
Where should an axis end, on the data or on the next round number past it? Padding to a round number leaves part of the panel blank, and implies that the data reaches further than it does.
An axis shows round numbers on its ticks, but the range follows your data, not the round numbers. Each tick is a round number that falls inside the data, or at most a little past it into the margin; the axis does not stretch out to the next ten just to end on one. A span of 1952–2007 is drawn as 1952–2007, with ticks at 1960, 1980 and 2000, not padded to 1940–2020 with a third of the panel left blank:
| country | continent | year | life | population | gdp |
|---|---|---|---|---|---|
| China | Asia | 1952 | 44.00000 | 556263527 | 400.4486 |
| China | Asia | 1957 | 50.54896 | 637408000 | 575.9870 |
| China | Asia | 1962 | 44.50136 | 665770000 | 487.6740 |
| China | Asia | 1967 | 58.38112 | 754550000 | 612.7057 |
| China | Asia | 1972 | 63.11888 | 862030000 | 676.9001 |
data(gapminder_asia) + area + x(year) + y(life) +
group(country) + style(opacity = 0.4) +
title("The year axis fits 1952–2007; the ticks fall inside it")(data(gapminder_asia) + area + x(col.year) + y(col.life) +
group(col.country) + style(opacity = 0.4) +
title("The year axis fits 1952–2007; the ticks fall inside it"))data(gapminder_asia) + area + x(:year) + y(:life) + group(:country) +
style(opacity = 0.4) +
title("The year axis fits 1952–2007; the ticks fall inside it")plot(data(gapminder_asia), area, x(col.year), y(col.life),
group(col.country), style({ opacity: 0.4 }),
title("The year axis fits 1952–2007; the ticks fall inside it"))“Given gapminder Asia: areas, x is year, y is life, grouped by country.”
Two more rules refine that fit, and the plot shows the reason for each. A free end leaves a small margin, so a mark never touches the panel edge. A point at the exact maximum would otherwise be clipped by the panel edge:
data(gapminder_2007) + point + x(gdp) + y(life) + color(continent) +
x_label("GDP per capita") + title("Every point sits clear of the panel edge")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + color(col.continent) +
x_label("GDP per capita") + title("Every point sits clear of the panel edge"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
x_label("GDP per capita") +
title("Every point sits clear of the panel edge")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), x_label("GDP per capita"),
title("Every point sits clear of the panel edge"))A baseline gets no margin. When a bar or an area measures from zero, that zero is a real coordinate. A gap beneath it would draw the baseline in the wrong place, so the baseline sits on the panel edge. An area has no glyph to clip, so along the axis it fills it runs to both panel edges. That is why the year area spans the full width while the income scatterplot keeps its margins: a fill’s edges are the shape, a point’s are not.
22.8 A scale is not only for axes
x and y turn a number into a position, but so do color, size and opacity: a ramp position, a radius, a transparency. All five answer how far along?, so all five take a scale. In three dimensions z takes one too, but its axis is linear, so a log depth is refused: see space.
This matters most for a column spread across orders of magnitude. population runs from about 200 thousand to 1.3 billion. On a linear ramp the median country sits in the bottom 2% of that range, so nearly every point gets the same color:
data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
color(population) +
x_label("GDP per capita") + title("Linear color: nearly one flat color")(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
color(col.population) +
x_label("GDP per capita") + title("Linear color: nearly one flat color"))data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
color(:population) + x_label("GDP per capita") +
title("Linear color: nearly one flat color")plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
y(col.life), color(col.population), x_label("GDP per capita"),
title("Linear color: nearly one flat color"))data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
color(population, scale = "log") +
x_label("GDP per capita") + title("Log color: the ramp is actually used")(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
color(col.population, scale = "log") +
x_label("GDP per capita") + title("Log color: the ramp is actually used"))data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
color(:population, scale = "log") + x_label("GDP per capita") +
title("Log color: the ramp is actually used")plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
y(col.life), color(col.population, { scale: "log" }),
x_label("GDP per capita"),
title("Log color: the ramp is actually used"))“Given gapminder 2007: points, x is gdp on a log scale, y is life, color by population on a log scale.”
The same is true of size, where a linear scale leaves almost every point at the minimum radius:
data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
size(population, scale = "log") + style(color = "steelblue") +
x_label("GDP per capita") + title("Log size")(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
size(col.population, scale = "log") + style(color = "steelblue") +
x_label("GDP per capita") + title("Log size"))data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
size(:population, scale = "log") + style(color = "steelblue") +
x_label("GDP per capita") + title("Log size")plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
y(col.life), size(col.population, { scale: "log" }),
style({ color: "steelblue" }), x_label("GDP per capita"),
title("Log size"))“Given gapminder 2007: points, x is gdp on a log scale, y is life, size by population on a log scale.”
Look again at the middle row of the two color legends further up. On a linear ramp it is the arithmetic midpoint; on a log ramp it is the geometric one: √(min · max), about 16M rather than 660M. It has to be, because that label names the color painted at the middle of the color ramp.
shape and group take no scale argument. They answer which one?, and there is no distance between circle and square for a scale to run along.
22.10 The column’s type picks the axis
Other tools make you name a scale whenever you want to change one, so scale = "category" is a habit a reader may arrive with.
The scales in the table above are not all chosen the same way. You ask for log. You do not ask for time or category. gog reads those off the column: a Date column gets a calendar axis, and a column of text gets one slot per distinct value.
You may still write them. Writing scale = "category" on a categorical column is allowed and changes nothing, in the same way scale = "linear" on a number changes nothing. The plot below is identical to the same sentence with the scale left off:
data(gapminder_2007) + bar * mean + x(continent, scale = "category") + y(life) +
title("Writing it changes nothing")(data(gapminder_2007) + bar * mean + x(col.continent, scale = "category") + y(col.life) +
title("Writing it changes nothing"))data(gapminder_2007) + bar * mean + x(:continent, scale = "category") +
y(:life) + title("Writing it changes nothing")plot(data(gapminder_2007), layer(bar, mean),
x(col.continent, { scale: "category" }), y(col.life),
title("Writing it changes nothing"))“Given gapminder 2007: bars derived by mean, x is continent as a category, y is life.”
What you cannot do is use a scale to contradict the column. Ask for a categorical axis on a number and gog refuses:
data(gapminder_2007) + point + x(gdp, scale = "category") + y(life)data(gapminder_2007) + point + x(col.gdp, scale = "category") + y(col.life)data(gapminder_2007) + point + x(:gdp, scale = "category") + y(:life)plot(data(gapminder_2007), point, x(col.gdp, { scale: "category" }),
y(col.life))Error:
! gog: `x(gdp, scale = "category")` — a scale says how a measured column is placed; whether an axis measures at all is the column's type. Removing the scale alone would leave the continuous axis you were trying to escape. Make `gdp` text — in R, `factor(gdp)` — and drop the scale. To cut the numbers into ranges instead, use `bin`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The reason behind that refusal also explains the others in this chapter. A scale says how a measured column is placed. Whether the axis measures at all is the column’s type. So log on a categorical column is refused, time on a plain number is refused, and a date column cannot be turned back into raw numbers by asking for linear. Every one of those refusals tells you to change the column, because the column is where the answer lives.
There are two things you might mean by “draw this number as categories”, and gog has a word for each. For one slot per distinct value, declare the column’s categories: factor() in R and ordered() in the other three bindings, both shown in Data. For ranges, use bin, which cuts the numbers into intervals and is covered in Transforms.
22.11 tick_count: how many ticks the axis aims for
An axis picks five ticks or so by default, and the count is the one part of the fit you may want to choose. A crowded axis is easier to read with fewer; an axis someone will take numbers from is easier with more. tick_count is written on the binding, beside scale, base and limits, and for the same reason. How many ticks an axis gets is a property of the scale, so it belongs to the sentence that describes the scale rather than to theme(), which controls the page.
(data(gapminder_2007) + point + x(gdp, tick_count = 3) + y(life) +
x_label("GDP per capita") + title("A low count: the shape, not the numbers")) |
(data(gapminder_2007) + point + x(gdp, tick_count = 12) + y(life) +
x_label("GDP per capita") + title("A high count: for reading values off"))((data(gapminder_2007) + point + x(col.gdp, tick_count = 3) + y(col.life) +
x_label("GDP per capita") + title("A low count: the shape, not the numbers")) |
(data(gapminder_2007) + point + x(col.gdp, tick_count = 12) + y(col.life) +
x_label("GDP per capita") + title("A high count: for reading values off")))(data(gapminder_2007) + point + x(:gdp, tick_count = 3) + y(:life) +
x_label("GDP per capita") +
title("A low count: the shape, not the numbers")) |
(data(gapminder_2007) + point + x(:gdp, tick_count = 12) + y(:life) +
x_label("GDP per capita") +
title("A high count: for reading values off"))beside(plot(data(gapminder_2007), point, x(col.gdp, { tick_count: 3 }),
y(col.life), x_label("GDP per capita"),
title("A low count: the shape, not the numbers")),
plot(data(gapminder_2007), point, x(col.gdp, { tick_count: 12 }),
y(col.life), x_label("GDP per capita"),
title("A high count: for reading values off")))“Given gapminder 2007: points, x is gdp with 3 ticks, y is life, beside the same with 12 ticks.”
The count is a target rather than a promise, and the difference is worth understanding. The count chooses a step, and the step is then rounded to a number a person would pick, so asking for eight on an axis running 0 to 100 gets a step of 10 and eleven ticks. What you can rely on is the direction: ask for more and you never get fewer, and every tick still falls on a round number.
Both panels above cover the same range. That is the rule the count obeys: it changes how densely an axis is labeled, never where the axis stops. Ask for two ticks and the axis does not shrink to the two values it draws.
data(gapminder_asia) + line + x(year, tick_count = 2) + y(life) +
group(country) + style(opacity = 0.4) +
title("Two ticks, and still 1952 to 2007")(data(gapminder_asia) + line + x(col.year, tick_count = 2) + y(col.life) +
group(col.country) + style(opacity = 0.4) +
title("Two ticks, and still 1952 to 2007"))data(gapminder_asia) + line + x(:year, tick_count = 2) + y(:life) +
group(:country) + style(opacity = 0.4) +
title("Two ticks, and still 1952 to 2007")plot(data(gapminder_asia), line, x(col.year, { tick_count: 2 }),
y(col.life), group(col.country), style({ opacity: 0.4 }),
title("Two ticks, and still 1952 to 2007"))A categorical axis has one tick per category, so the count there is the data’s rather than yours, and the refusal points at the two fixes that do the jobs you might have meant:
data(gapminder_2007) + bar * mean + x(continent, tick_count = 3) + y(life)data(gapminder_2007) + bar * mean + x(col.continent, tick_count = 3) + y(col.life)data(gapminder_2007) + bar * mean + x(:continent, tick_count = 3) +
y(:life)plot(data(gapminder_2007), layer(bar, mean),
x(col.continent, { tick_count: 3 }), y(col.life))Error:
! gog: `x(continent, tick_count = 3)` — `continent` is text, and a categorical axis has one tick per category, so the count is the data's rather than yours. To change which categories appear, filter the table before plotting; to change their order, use `order(continent)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
And a legend is not a short axis. limits reaches every channel that measures, because every one of them has a domain; tick_count reaches only the three position channels that draw an axis: x, y and z. A color legend names three rows, both ends and the middle, and those come from the scale’s own shape rather than from a count you could choose. On a log ramp the middle row is the geometric mean, the label for the color actually painted halfway along the ramp. So color(), size() and opacity() have no tick count to set, the same way shape() has no scale and no limits: there is nothing for the count to describe.
22.11.1 Crowding in the plane and in the cube
A flat panel and a cube count their ticks differently, and neither answer is wrong. A flat axis draws every tick it chose, however tight they get. The panel’s width is already fixed, and the labels sit in a margin reserved for them. An edge label that would overhang its panel is anchored inward instead. Nothing is dropped, so what you asked for is what you count.
A cube cannot make that promise. An axis in space is drawn at whatever length the viewing angle leaves it. Tilted far enough, it has no room for its numbers, so the cube draws as many as fit and leaves the rest out. Both are the same rule underneath, do not print one number through another. They differ only in how much room each has, because the plane can reserve room in advance and the cube cannot. When a count you stated has to be thinned that way, the engine says so rather than quietly drawing fewer.