21  Scales

A scale maps data values to something visible: a place along an axis, a radius, an opacity, a color, or a symbol. Every plot has scales, whether or not you set any. This chapter is about setting them.

Scales are named in several other chapters – column-to-aesthetic mapping in Core Concepts, the full mapping form in Specifying Aesthetics, free domains in Faceting, and the catalogue of named palettes and gradients in Customization. What a scale is, and what each one takes, is here.

(ns plotje-book.scales
  (:require
   ;; Kindly -- notebook rendering protocol
   [scicloj.kindly.v4.kind :as kind]
   ;; Plotje -- composable plotting
   [scicloj.plotje.api :as pj]
   ;; Plotje -- the layer-type registry, where a mark declares what it varies
   [scicloj.plotje.layer-type :as layer-type]
   ;; Rdatasets -- standard datasets
   [scicloj.metamorph.ml.rdatasets :as rdatasets]
   ;; Tablecloth -- dataset manipulation
   [tablecloth.api :as tc]
   ;; Tablecloth -- column-level operations
   [tablecloth.column.api :as tcc]))

The examples use one year of the gapminder data.

(def gapminder-2007
  (-> (rdatasets/gapminder-gapminder)
      (tc/select-rows #(= 2007 (:year %)))))
gapminder-2007

https://vincentarelbundock.github.io/Rdatasets/csv/gapminder/gapminder.csv [142 7]:

:rownames :country :continent :year :life-exp :pop :gdp-percap
12 Afghanistan Asia 2007 43.828 31889923 974.5803384
24 Albania Europe 2007 76.423 3600523 5937.0295260
36 Algeria Africa 2007 72.301 33333216 6223.3674650
48 Angola Africa 2007 42.731 12420476 4797.2312670
60 Argentina Americas 2007 75.320 40301927 12779.3796400
72 Australia Oceania 2007 81.235 20434176 34435.3674400
84 Austria Europe 2007 79.829 8199783 36126.4927000
96 Bahrain Asia 2007 75.635 708573 29796.0483400
108 Bangladesh Asia 2007 64.062 150448339 1391.2537920
120 Belgium Europe 2007 79.441 10392226 33692.6050800
1584 Turkey Europe 2007 71.777 71158647 8458.2763840
1596 Uganda Africa 2007 51.542 29170398 1056.3801210
1608 United Kingdom Europe 2007 79.425 60776238 33203.2612800
1620 United States Americas 2007 78.242 301139947 42951.6530900
1632 Uruguay Americas 2007 76.384 3447496 10611.4629900
1644 Venezuela Americas 2007 73.747 26084662 11415.8056900
1656 Vietnam Asia 2007 74.249 85262356 2441.5764040
1668 West Bank and Gaza Asia 2007 73.422 4018332 3025.3497980
1680 Yemen, Rep. Asia 2007 62.698 22211743 2280.7699060
1692 Zambia Africa 2007 42.384 11746035 1271.2115930
1704 Zimbabwe Africa 2007 43.487 12311143 469.7092981

Two of its columns cover very wide intervals, where a log scale and a linear one differ most:

(-> gapminder-2007
    (tc/aggregate-columns [:gdp-percap :pop]
                          (fn [xs]
                            {:min (tcc/reduce-min xs)
                             :max (tcc/reduce-max xs)})))

_unnamed [1 4]:

:gdp-percap-min :gdp-percap-max :pop-min :pop-max
277.5518587 49357.19017 199579 1318683096

The parts of a scale

Three parts come up in most of what follows, and this chapter has a section for each. The domain is the data side of a scale, the range is the visible side, and the type is the relation between them.

  • The type decides how a data value becomes a drawn one. A :linear type maps equal differences in the data to equal differences in what is drawn, a :log type does the same for equal ratios, and a :categorical type gives each distinct value a place of its own.

  • The domain is the extent of data values the scale reads: the lowest and highest value for a continuous scale, or the list of distinct categories for a categorical one. Plotje takes it from the column the aesthetic is mapped to unless the scale spec sets it.

  • The range is the extent of drawn values the scale produces. For :x and :y it is the panel, measured in drawing units, whose size follows from the plot’s :width and :height. For :size it is an interval of radii in drawing units, for :alpha an interval of opacities, for :color a palette or a gradient, for :fill a gradient, and for :shape a list of symbols.

Written down, these go in a scale spec – the map that says which scale, and how. Two of the three are written the same way throughout: :type and :domain are keys on every aesthetic that has a scale. The range is not. :size and :alpha take a :range, :shape names its symbols with :values, :color names a palette with :values and a gradient with :range, and an axis has no range to set, because the panel decides it. A spec can also carry keys that are none of the three parts, such as :by on :size or the tick keys on an axis. So the table further down lists :type and :domain once, and then what each aesthetic reads beside them.

A plot usually has several scales at once, and each one is set separately.

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:size :pop :color :continent}))
life expgdp percapcontinentAsiaEuropeAfricaAmericasOceaniapop2000000004000000006000000008000000001000000000120000000001000020000300004000050000404550556065707580

The pose maps four aesthetics, and the plot has one scale for each: :gdp-percap and :life-exp on the two axes, :pop on size, and :continent on color. The two that have no axis are explained by legends.

Where a scale is set

A scale belongs to the aesthetic it reads, so it is set in that aesthetic’s mapping. There are two ways to write it, and both end up in the same place.

pj/scale is the first. Its second argument is the aesthetic, which can be either axis or any appearance one: :x, :y, :color, :size, :alpha, :fill or :shape. The examples in this section use :x, but the same call works for the others, and each aesthetic is a separate setting.

A scale set this way applies to the pose it is called on and to everything below it.

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp)
    (pj/scale :x :log))
life expgdp percap100100010000100000404550556065707580

Written on one pose of a composite, a scale covers that pose alone, so two of them can differ:

(def linear-cell
  (-> gapminder-2007
      (pj/lay-point :gdp-percap :life-exp)
      (pj/options {:title "linear"})))
(def log-cell
  (-> linear-cell
      (pj/scale :x :log)
      (pj/options {:title "log"})))
(pj/arrange [linear-cell log-cell])
linearlife expgdp percap01000020000300004000050000404550556065707580loglife expgdp percap100100010000100000404550556065707580

On the left most countries are pressed against the edge, because a few rich ones stretch the axis. The log plot spreads them out, since equal distances there mean equal ratios.

The second way is to write the mapping out in full and give it a :scale key, which sets the scale for that mapping alone. Here :size reads population through a log scale, so that the smaller countries are still distinguishable; the x axis is log-scaled separately, by pj/scale:

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:size {:column :pop :scale :log}})
    (pj/scale :x :log))
life expgdp percappop1000001000000100000001000000001000000000100100010000100000404550556065707580

A type, or a map

Both ways take the scale in the same two forms: a type keyword, or a spec map. The keyword is shorthand – :log is read as {:type :log} where it is written, and every stage after that reads the map.

With pj/scale, the two spellings leave the same scale on the axis:

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp)
    (pj/scale :x :log)
    pj/plan
    :panels first :x-scale)
{:type :log}
(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp)
    (pj/scale :x {:type :log})
    pj/plan
    :panels first :x-scale)
{:type :log}

and in a mapping for the same aesthetic they do the same:

(-> gapminder-2007
    (pj/lay-point {:x {:column :gdp-percap :scale :log} :y :life-exp})
    pj/plan
    :panels first :x-scale)
{:type :log}
(-> gapminder-2007
    (pj/lay-point {:x {:column :gdp-percap :scale {:type :log}} :y :life-exp})
    pj/plan
    :panels first :x-scale)
{:type :log}

Which keys the map may carry depends on the aesthetic, and a key the aesthetic does not read is refused rather than ignored – in pj/scale and in a mapping alike:

(try
  (-> gapminder-2007
      (pj/lay-point :gdp-percap :life-exp)
      (pj/scale :x {:rnge [1 10]}))
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"pj/scale :x {:rnge [1 10]} has unexpected key(s): [:rnge]. :x's scale takes [:breaks :domain :label :n-ticks :tick-labels :tick-spacing :type]."

Scales accumulate

Wherever a scale is set – with pj/scale, in a pose’s mapping, in a layer’s mapping – the settings accumulate down the scope chain, and the innermost wins for each key it names. So a range set once on the pose and a type named on a layer give a plot with both:

(-> gapminder-2007
    (pj/pose :gdp-percap :life-exp {:size {:column :pop :scale {:range [3 16]}}})
    (pj/lay-point {:size {:column :pop :scale :log}})
    (pj/scale :x :log))
life expgdp percappop1000001000000100000001000000001000000000100100010000100000404550556065707580

The same holds when the range comes from pj/scale rather than from the pose’s mapping:

(-> gapminder-2007
    (pj/pose :gdp-percap :life-exp)
    (pj/lay-point {:size {:column :pop :scale :log}})
    (pj/scale :size {:range [3 16]})
    (pj/scale :x :log))
life expgdp percappop1000001000000100000001000000001000000000100100010000100000404550556065707580

The rest of a mapping is replaced by the mapping below it, because a mapping states one source and two sources cannot combine – {:column :n :value 7} is refused by name. A scale is a set of independent settings, so it accumulates.

:scale false does not accumulate: it says the value passes through no scale at all, so it replaces whatever was set above.

A column read this way has to hold what the aesthetic draws. For :size that is a radius in drawing units, so the column below holds 4, 8 and 12 – not a population count, which would ask for circles millions of units across:

(def measured-radii
  [{:reading 1 :level 2 :spread 4}
   {:reading 2 :level 5 :spread 8}
   {:reading 3 :level 3 :spread 12}])
(-> measured-radii
    (pj/pose :reading :level {:size {:column :spread :scale {:range [3 16]}}})
    (pj/lay-point {:size {:column :spread :scale false}}))
levelreading1.01.21.41.61.82.02.22.42.62.83.02.02.53.03.54.04.55.0

On :x and :y, :scale false means the value is a distance in drawing units from the top left of the panel background rather than a data value, so the mark is placed on the panel rather than in the data. One layer can be placed on the panel while another is placed in the data, and {:in :drawing-area} says the same for both axes of a layer at once. See Placing Marks.

:scale true says only that the value passes through the aesthetic’s scale. It sets no type and no other key, so the scale it passes through is whatever pj/scale set on the pose, or the default where nothing set one.

pj/scale compared with a mapping’s :scale

They write the same thing in the same place, so for an aesthetic mapped on the pose they produce the same plot. The scale the size mapping is read through, written in the mapping:

(-> gapminder-2007
    (pj/pose :gdp-percap :life-exp {:size {:column :pop :scale :log}})
    pj/lay-point
    pj/plan
    :panels first :layers first :size-scale)
{:type :log}

and the same mapping with the scale set by pj/scale:

(-> gapminder-2007
    (pj/pose :gdp-percap :life-exp {:size :pop})
    pj/lay-point
    (pj/scale :size :log)
    pj/plan
    :panels first :layers first :size-scale)
{:type :log}

:scale is written where the mapping is, so the source can be named beside it. pj/scale names no source, so it can set the scale for an aesthetic that is mapped further in – on a layer below the pose the call was made on. Everything a spec can carry is available in both.

What each aesthetic’s scale takes

pj/aesthetic-scales is the table pj/scale and a mapping’s :scale are both checked against, so the one below is read out of Plotje rather than written down beside it. :types are the types the aesthetic can be read through, and :keys are the spec keys it reads beside :type and :domain.

(tc/dataset pj/aesthetic-scales)

_unnamed [7 3]:

:aesthetic :types :keys
:x [:linear :log :categorical] [:breaks :tick-labels :n-ticks :tick-spacing :label]
:y [:linear :log :categorical] [:breaks :tick-labels :n-ticks :tick-spacing :label]
:size [:linear :log] [:range :by :from-zero :label]
:alpha [:linear :log] [:range :from-zero :label]
:color [:linear :log] [:range :values :midpoint :label]
:fill [:linear :log] [:range :midpoint :label]
:shape [:categorical] [:values :label]

A spec key an aesthetic does not read is refused where it is written, rather than accepted and ignored. An axis has no range to set, because the panel size determines it:

(try
  (-> gapminder-2007
      (pj/lay-point :gdp-percap :life-exp)
      (pj/scale :x {:range [1 10]}))
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"pj/scale :x :range [1 10], and :x reads no :range. A range is what a mark spans as the value runs across the domain, and :x spans the panel, which the plot's size decides. The aesthetics that read :range: [:alpha :color :fill :size]."

The aesthetics absent from the table have no scale to set. :text and :group have none at all: a label is drawn as written, and grouping only splits the data into drawn groups, so :scale is refused on both. The aesthetics drawn through the axes – :x-end, :x-min, :x-max, :y-min, :y-max – take no :scale either. Their values are read on the panel’s axis, which is where that scale is set.

Type

The type decides how a data value becomes a drawn one.

  • A :linear type maps equal differences in the data to equal differences in what is drawn, so a difference of ten covers the same part of the range wherever in the domain it falls.

  • A :log type maps equal ratios in the data to equal differences in what is drawn, so each factor of ten covers the same part of the range. This is what makes a column spanning orders of magnitude readable.

  • A :categorical type has no arithmetic to preserve. It gives each distinct value in the domain a place of its own: a band on an axis, one color from a palette, or one symbol.

The table above says which types each aesthetic accepts: the axes take all three, :size, :alpha, :color and :fill take :linear and :log, and :shape takes :categorical alone. Asking an aesthetic for a type it does not accept is refused, and the message lists the ones it does:

(try
  (-> gapminder-2007
      (pj/lay-point :gdp-percap :life-exp {:color :continent})
      (pj/scale :color :categorical)
      pj/plan)
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"The aesthetic :color is continuous and does not support a :categorical scale. Supported: [:linear :log]."

That refusal is about the scale type, not about the column: a categorical column mapped to :color is drawn from a palette without any type being named. Which of the two happens is settled below.

A log scale on an appearance aesthetic

:log is not only for axes. Values that jump by factors of ten crowd together at the bottom of a size range under the default :linear, because a linear scale reads absolute distance, which the largest value dominates. Here the step from 10 to 100 is a small part of the way to 1000:

(def by-tens
  {:user [:a :b :c] :n [10 100 1000]})
(-> by-tens
    (pj/lay-point :user :n {:size :n :x-type :categorical}))
nusern2004006008001000abc01002003004005006007008009001000

Under :log each factor of ten covers the same part of the range, so the two smaller points are no longer pressed together – the gap between them is now the wider of the two:

(-> by-tens
    (pj/lay-point :user :n {:size :n :x-type :categorical})
    (pj/scale :size :log))
nusern101001000abc01002003004005006007008009001000

A :color or :fill gradient is spaced the same way. Under :log each factor covers the same part of the gradient, so the countries below the top of the population range are no longer all one shade:

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:color :pop})
    (pj/scale :color :log)
    (pj/scale :x :log))
life expgdp percappop1000001000000100000001000000001000000000100100010000100000404550556065707580

What settles continuous or categorical

Nothing above asked for a continuous scale or a categorical one. The column’s type decides, and the scale’s type does not: a column of numbers is read as a quantity, a column of anything else as a set of categories, and a scale’s :type then chooses between :linear and :log for the numbers. Writing :categorical as a scale type cannot make a column categorical – on a numeric column it is refused, and on a categorical one the scale’s :type is not read at all. Column Types sets the two side by side.

On an axis the column’s type is the difference between a measured line and a row of bands, and a column of names takes the bands without being asked:

(-> gapminder-2007
    (pj/lay-bar :continent))
continentAsiaEuropeAfricaAmericasOceania05101520253035404550

Where the column’s type is not what the values mean, :x-type, :y-type and :color-type say so in the mapping. :cyl counts cylinders – 4, 5, 6 and 8 – and the numbers stand for kinds of engine rather than for a quantity to measure along. :x-type :categorical says so, and each count gets its own band:

(-> (rdatasets/ggplot2-mpg)
    (pj/lay-point :cyl :hwy {:x-type :categorical}))
hwycyl468515202530354045

:color reads the same column the same way. Left alone, four counts of cylinders are drawn through a gradient, as a continuous quantity:

(-> (rdatasets/ggplot2-mpg)
    (pj/lay-point :displ :hwy {:color :cyl}))
hwydisplcyl4823456715202530354045

:color-type :categorical reads them as categories instead, and a palette with one entry per count replaces the gradient:

(-> (rdatasets/ggplot2-mpg)
    (pj/lay-point :displ :hwy {:color :cyl :color-type :categorical}))
hwydisplcyl468523456715202530354045

Both plots put the five-cylinder cars last, because the bands and the legend rows follow the order the rows arrive in. Ordering them is what a :domain is for.

What Plotje infers from a column, and every way of overriding it, is in Inference Rules.

Domain

The domain is the extent of data values the scale reads. Plotje takes it from the column the aesthetic is mapped to – its lowest and highest value, or its distinct categories – and a :domain in the scale spec replaces that.

The column’s type decides how a written :domain is read, not the shape of the domain written. Against a continuous column a :domain replaces the interval; against a categorical one it supplies the order of the categories, however many are listed.

On an axis: a view window

A :domain on :x or :y sets what the panel shows. It does not remove rows. A mark outside the window is drawn and clipped at the panel edge, so the data behind the plot is unchanged. This matches ggplot2’s coord_cartesian, which zooms the view, rather than its scale limits, which drop rows.

Here the sepal-width domain is tightened to a band the data overflows in both directions. All 150 observations are still rendered, behind one clip region:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/scale :y {:domain [3.0 3.5]}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.03.03.053.13.153.23.253.33.353.43.453.5

Where the ticks go

An axis scale carries the keys that place and word its tick marks. :breaks pins the values that get a tick, which is ggplot2’s scale_*_continuous(breaks=):

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp)
    (pj/scale :y {:breaks [40 50 60 70 80]}))
life expgdp percap050001000015000200002500030000350004000045000500004050607080

:tick-labels pairs custom text with those breaks, one label each. It is how an axis that is numerically indexed gets worded labels – days of the week along a heatmap indexed 1 to 7, for instance. That case is worked through in Customization.

On a categorical axis the same two keys select and reword. Each break is matched to a category by the text it is drawn with, and a break naming no category is dropped with a warning. Here two of the four quarters are ticked, with text of their own:

(-> {:quarter ["Q1" "Q2" "Q3" "Q4"]
     :revenue [120 150 90 200]}
    (pj/lay-bar :quarter :revenue)
    (pj/scale :x {:breaks ["Q1" "Q4"] :tick-labels ["First" "Fourth"]}))
revenuequarterFirstFourth020406080100120140160180200

A categorical axis labels every category by default, so with many of them the labels overlap. :n-ticks keeps roughly that many evenly-spaced labels instead. Explicit :breaks win where both are given, and rotating the labels with :x-tick-angle is the other answer to crowding – see Customization.

(-> {:bin (map #(str "bin-" %) (range 40))
     :count (range 40)}
    (pj/lay-bar :bin :count)
    (pj/scale :x {:n-ticks 8}))
countbinbin-0bin-5bin-10bin-15bin-20bin-25bin-30bin-350510152025303540

A numeric axis reads :n-ticks too. Its ticks are chosen rather than listed, so the count is a target: the chooser rounds to values a reader can read off, and may land near the number asked for rather than on it.

(-> {:hour (range 20) :load (range 20)}
    (pj/lay-point :hour :load)
    (pj/scale :x {:n-ticks 3}))
loadhour051015024681012141618

:tick-spacing asks the same question the other way round: about how much room, in drawing units, each tick should have. The count is then how many fit. It is a target in the way :n-ticks is, and for the same reason – the chooser still rounds to values a reader can read off, so the room each tick ends up with can come out under the number asked for.

(-> {:hour (range 20) :load (range 20)}
    (pj/lay-point :hour :load)
    (pj/scale :x {:tick-spacing 200}))
loadhour010024681012141618

:x-tick-spacing and :y-tick-spacing in pj/options name that setting one scope further out, and a spec wins over them. There is nothing for a spacing to steer on a categorical axis, whose ticks are its categories, so one written in a spec there says so and leaves the categories alone.

The axis title is a separate key, :label, which every aesthetic reads – see Titling a scale.

Ordering categories

On a categorical column a :domain supplies the order of the categories – the order of the bands on an axis, and of the rows in a legend. The continents above arrived in the order the data holds them; this puts them in another:

(-> gapminder-2007
    (pj/lay-bar :continent)
    (pj/scale :x {:domain ["Oceania" "Africa" "Asia" "Americas" "Europe"]}))
continentOceaniaAfricaAsiaAmericasEurope05101520253035404550

Categories are matched by the text they are drawn with, so a column of numbers read as categories can be ordered by those numbers. The five-cylinder cars go back where a reader expects them:

(-> (rdatasets/ggplot2-mpg)
    (pj/lay-point :cyl :hwy {:x-type :categorical})
    (pj/scale :x {:domain [4 5 6 8]}))
hwycyl456815202530354045

:color, :fill and :shape read a categorical :domain the same way. For :color and :fill the palette is assigned in the order given, so the domain moves the legend rows and the colors together: the first category listed takes the palette’s first color wherever it sits in the data.

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:color :continent})
    (pj/scale :color {:domain ["Oceania" "Europe" "Asia" "Americas" "Africa"]})
    (pj/scale :x :log))
life expgdp percapcontinentOceaniaEuropeAsiaAmericasAfrica100100010000100000404550556065707580

The number of categories listed changes nothing. A column holding exactly two numeric categories is ordered by the same :domain on whichever aesthetic reads it:

(def two-grades
  {:grade [1 2 1 2]
   :hours [3 9 2 11]
   :score [72 88 64 91]})
(-> two-grades
    (pj/lay-point :hours :score {:color :grade :color-type :categorical})
    (pj/scale :color {:domain [2 1]}))
scorehoursgrade21246810657075808590

A category the domain leaves out is still drawn, ordered after the ones listed, and Plotje says so – an incomplete list is usually a typo or a stale set of names rather than a request.

On an appearance aesthetic

A size, an opacity or a color has no panel to clip against, so a numeric :domain there is not a view window. A value outside it is drawn at the nearer end of the range: the smallest radius, the faintest opacity, the end of the gradient.

Population runs to well over a billion, and a handful of countries at that end leave everywhere else crowded into one shade. Ending the gradient at fifty million spreads the countries most of the data holds:

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:color :pop})
    (pj/scale :color {:domain [0 5.0E7]})
    (pj/scale :x :log))
life expgdp percappop050000000100100010000100000404550556065707580

The countries above fifty million are drawn at the light end rather than dropped. A domain says what the reader should compare, and a dropped row would leave no trace on the panel to say so.

Fixing the domain this way is also how every panel of a facet is given one size or color scale to share – see Faceting.

Range

The range is the extent of drawn values the scale produces: an interval of radii for a :size column, an interval of opacities for an :alpha column, a gradient for a numeric :color or :fill one. Those four aesthetics write it under a :range key – two numbers for the first pair, a gradient for the second.

An axis has none to set, since the panel size determines it – the refusal is above. :shape spans a list of symbols, written with :values rather than :range, because symbols are named one by one rather than spanned between two ends; a categorical :color spans a palette, written the same way. The section below draws the color cases.

The smallest and largest value in a column are drawn at the two ends of the range, so the default can be read off the marks themselves. These are the distinct radii a default size scale draws, in drawing units, smallest first:

(def squares
  {:step [1 2 3 4 5 6] :row [1 1 1 1 1 1] :n [1 4 9 16 25 36]})
(-> squares
    (pj/lay-point :step :row {:size :n})
    pj/svg-summary
    :sizes)
#{2.0
  3.75662013130736
  4.868548662402545
  5.927922024247863
  6.96847202726495
  8.0}

A wider range draws every mark wider:

(-> squares
    (pj/lay-point :step :row {:size :n})
    (pj/scale :size {:range [3 20]}))
rowstepn510152530351234560.00.20.40.60.81.01.21.41.61.82.0

:alpha takes a range of opacities in the same way. The one written out below is the default, so it draws the same plot as leaving it alone – China and India nearly opaque, most of the world faint:

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:alpha :pop})
    (pj/scale :alpha {:range [0.1 1.0]})
    (pj/scale :x :log))
life expgdp percappop20000000040000000060000000080000000010000000001200000000100100010000100000404550556065707580

How a size spreads across its range

A circle with twice the radius covers four times the area. Readers compare the area, so the way values spread across the radii changes what the plot appears to say.

:by sets that spreading. All three read a value as how far along the domain it falls – at the smallest value in the column, at the largest, or some way between – and differ in what they spread evenly across the range:

  • :sqrt (the default) spreads the square root of that distance across the radii, which is what ggplot2’s scale_size does. The smallest value is drawn at the low end of the range rather than at zero.

  • :linear spreads the distance itself across the radii, as ggplot2’s scale_radius does. Differences in area then exaggerate differences in value.

  • :area spreads the area, so equal steps in value give equal steps in area.

It is the distance along the domain, not the value, so a column and the same column scaled up draw the same circles.

The same six values under all three:

(-> (pj/arrange
     [(-> squares (pj/lay-point :step :row {:size :n})
          (pj/options {:title ":sqrt (default)"}))
      (-> squares (pj/lay-point :step :row {:size :n}) (pj/scale :size {:by :linear})
          (pj/options {:title ":linear"}))
      (-> squares (pj/lay-point :step :row {:size :n}) (pj/scale :size {:by :area})
          (pj/options {:title ":area"}))])
    (pj/options {:width 900 :height 340}))
:sqrt (default)rowstepn51015202530352460.00.51.01.52.0:linearrowstepn51015202530352460.00.51.01.52.0:arearowstepn51015202530352460.00.51.01.52.0

All three draw the smallest value at the low end of the range and the largest at the high end. They differ for the values in between. The legend is computed from the same function as the marks, so its sizes show the difference:

(defn legend-magnitudes
  [spec]
  (mapv :magnitude
        (-> squares
            (pj/lay-point :step :row {:size :n})
            (pj/scale :size spec)
            pj/plan
            :size-legend
            :entries)))
(-> (for [by [:linear :area :sqrt]
          :let [ms (legend-magnitudes {:by by})]]
      {:by by
       :smallest-labelled (first ms)
       :middle (nth ms (quot (count ms) 2))
       :largest-labelled (last ms)})
    tc/dataset)

_unnamed [3 4]:

:by :smallest-labelled :middle :largest-labelled
:linear 2.68571429 5.25714286 7.82857143
:area 3.29501788 6.04743157 7.89212990
:sqrt 4.02837021 6.42073039 7.91366456

Anchoring at zero

:from-zero starts both the domain and the range at zero. The area is then proportional to the value: twice the value is twice the area. Together with :by :area this matches ggplot2’s scale_size_area.

Anchored at zero it is the distance from zero that decides the ink, so a value of -5 draws the size a value of 5 draws and the domain reaches to whichever value is furthest from zero. A column holding both signs is drawn by magnitude, which is what proportional area means when the values run both ways – see Edge Cases for what that looks like, and map the sign to :color where it matters.

(-> squares
    (pj/lay-point :step :row {:size :n})
    (pj/scale :size {:by :area :from-zero true}))
rowstepn1020301234560.00.20.40.60.81.01.21.41.61.82.0

:alpha takes the key too. It asks a different question there – not how a value spreads, but whether the drawn quantity is proportional to it. An opacity can answer that, since zero opacity is absence the way zero area is:

(-> squares
    (pj/lay-point :step :row {:alpha :n})
    (pj/scale :alpha {:from-zero true}))
rowstepn1020301234560.00.20.40.60.81.01.21.41.61.82.0

:color cannot answer it, and does not take the key: the low end of a gradient is a color rather than an absence, so there is no quantity there for a value to be proportional to.

:from-zero cannot be combined with a log scale either, since a log scale has no value for zero. Setting both is refused.

Which quantity a mark draws

:by :area works the same way on every mark, because the quantity each mark draws an aesthetic as is known before any value is scaled. :point is the one built-in layer type that says so itself, under :varies: it draws :size as a radius and :alpha as an opacity. A registered mark drawing a stroke would say it draws a width. The quantity also fixes how the area grows with it: as the square for a radius, linearly for a width.

(:varies (layer-type/lookup :point))
{:size :radius, :alpha :opacity}

Where the area grows linearly with the quantity, the three :by methods give the same result, since there is no area correction to make. That is why :alpha takes :range but not :by: an opacity has no shape.

The declaration also decides which aesthetics get a legend. A mark that does not declare an aesthetic draws one value for the whole layer, so a column mapped to that aesthetic changes nothing. Plotje warns and draws no legend for it, and the warning names the marks that do vary it. A mark that does not take the aesthetic as a layer option at all – :bar and :area do not take :size – reports it as an option it does not recognize instead. Registering a mark that declares its own is covered in Extensibility.

The colors a scale spans

A :color scale draws from a palette where the column is categorical, and from a gradient where it is numeric. The column’s type decides which, and each has its own spec key: :values for the palette, :range for the gradient. A :range is spanned between two ends, as an interval of radii is for :size; a :values is enumerated and assigned by index, as the marker symbols are for :shape.

:fill takes only the gradient half. It draws a magnitude, so it needs a numeric column and reads no :values; a categorical column belongs on :color, and the message says so:

(try
  (-> {:hour [1 2 3] :day [1 2 3] :shift ["early" "late" "early"]}
      (pj/lay-tile :hour :day {:fill :shift})
      pj/plan)
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"Aesthetic :fill needs a numeric column, but :shift holds categories. :fill draws a magnitude, so there is nothing for a category to be. To tell categories apart, map the column to :color (a palette) or to :group (one drawn group each)."

A palette is a vector of colors, a map from category to color, or the name of a built-in one. It is assigned in domain order, so a :domain moves the colors together with the legend rows:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/scale :color {:values ["#E74C3C" "#3498DB" "#2ECC71"]}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Written as a map, a palette names the color for each category, so the pairing does not depend on the order the categories arrive in. The column still supplies the domain, so the legend is drawn from it as it always is:

(-> {:district ["a" "b" "c" "d" "e" "f"]
     :share [10 20 30 40 50 60]
     :party ["rep" "dem" "dem" "ind" "rep" "ind"]}
    (pj/lay-point :district :share {:color :party})
    (pj/scale :color {:values {"rep" "red" "dem" "blue" "ind" "green"}}))
sharedistrictpartyrepdemindabcdef1015202530354045505560

A gradient is named – :viridis and the rest of the catalogue, or the family names :sequential and :diverging – or written as a map of stops, where :low, :mid and :high build one of three and :mid may be left out for two. The third form is a function, taking a number from zero at the low end of the domain to one at the high end and answering with [r g b a], each from zero to one. It is the way to a gradient the other two forms cannot write.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :petal-length})
    (pj/scale :color {:range {:low "#2166AC" :mid "#F7F7F7" :high "#B2182B"}}))
sepal widthsepal lengthpetal length16.94.55.05.56.06.57.07.58.02.02.53.03.54.04.5

The same gradient written as a function, opaque throughout and running blue to red:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :petal-length})
    (pj/scale :color {:range (fn [t] [t 0.0 (- 1.0 t) 1.0])}))
sepal widthsepal lengthpetal length16.94.55.05.56.06.57.07.58.02.02.53.03.54.04.5

The range and the type are separate settings, and a plot may name both. The column below spans four orders of magnitude, so the gradient says which colors and the scale’s type says how the values are spaced along them; the legend labels the decades:

(-> {:step (range 40)
     :row (range 40)
     :n (map #(Math/pow 10 (/ % 10.0)) (range 40))}
    (pj/lay-point :step :row {:color :n})
    (pj/scale :color {:type :log :range :viridis}))
rowstepn11010010001000005101520253035400510152025303540

:midpoint names the value the middle of a gradient is drawn at. A diverging gradient reads as a departure from a centre, and without a midpoint that centre falls halfway along the data rather than where the reader expects it:

(-> {:region ["n" "s" "e" "w" "c"]
     :year [1 2 3 4 5]
     :change [-40 -10 5 30 60]}
    (pj/lay-point :year :change {:color :change})
    (pj/scale :color {:range :diverging :midpoint 0}))
changeyearchange-40601.01.52.02.53.03.54.04.55.0-40-30-20-100102030405060

Each of the three has a plot option of the same name, one scope further out: :color-values, :color-range and :color-midpoint, and :fill-range and :fill-midpoint for the other color aesthetic. They set the whole plot where a spec sets one mapping or one layer, and the spec wins where both are written.

(-> {:district ["a" "b" "c" "d" "e" "f"]
     :share [10 20 30 40 50 60]
     :party ["rep" "dem" "dem" "ind" "rep" "ind"]}
    (pj/lay-point :district :share {:color :party})
    (pj/options {:color-values {"rep" "grey" "dem" "grey" "ind" "grey"}})
    (pj/scale :color {:values {"rep" "red" "dem" "blue" "ind" "green"}}))
sharedistrictpartyrepdemindabcdef1015202530354045505560

Which palettes and gradients are available, and how to search them, is Customization.

The symbols :shape spans

:shape takes only a :categorical type, so what it spans is a list rather than an interval, and two things about that list can be set: which order the categories are assigned symbols in, and which symbols those are. Both matter when a reader compares two plots: one category should keep one marker across both.

pj/shape-symbols is the list, in the order categories take them:

pj/shape-symbols
[:circle :square :triangle :diamond :triangle-down :plus :cross]

A plot with more categories than that repeats a symbol, so two categories cannot be told apart; Plotje warns when it happens.

:domain sets the category order, as it does for :color, and :values names the symbols themselves, paired with the categories in that same order:

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:shape :continent})
    (pj/scale :shape {:domain ["Africa" "Americas" "Asia" "Europe" "Oceania"]
                      :values [:circle :square :triangle :diamond :cross]})
    (pj/scale :x :log))
life expgdp percapcontinentAfricaAmericasAsiaEuropeOceania100100010000100000404550556065707580

Titling a scale

A scale is explained to the reader by something drawn beside the marks: an axis for :x and :y, a legend for the rest. :label titles that thing, and every aesthetic with a scale reads it. Where none is written, the column’s name is drawn.

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:color :continent})
    (pj/scale :x {:type :log :label "GDP per capita, log scale"})
    (pj/scale :color {:label "Continent"}))
life expGDP per capita, log scaleContinentAsiaEuropeAfricaAmericasOceania100100010000100000404550556065707580

Each has a plot option of the same name one scope further out: :x-label and :y-label for the axes, and :color-label, :fill-label, :size-label, :alpha-label and :shape-label for the legends. They title the whole plot where a spec titles one mapping or one layer, and the spec wins where both are written.

(-> gapminder-2007
    (pj/lay-point :gdp-percap :life-exp {:color :continent})
    (pj/options {:color-label "From the options"})
    (pj/scale :color {:label "From the spec"}))
life expgdp percapFrom the specAsiaEuropeAfricaAmericasOceania01000020000300004000050000404550556065707580

One legend per aesthetic

A size legend’s swatches are computed from the same function as the marks, so on a single panel the swatch next to a value is the size a mark of that value is drawn at. Across the panels of a facet it is not, for the reason given under Not supported yet below.

A swatch is also drawn in the shape of the quantity it explains. One column mapped to both :size and :alpha earns two legends over the same values, and they look nothing alike: a radius is explained by graduated circles, an opacity by squares of one size at graduated opacities.

(-> squares
    (pj/lay-point :step :row {:size :n :alpha :n})
    (pj/options {:width 620}))
rowstepn5101520253035n51015202530351234560.00.20.40.60.81.01.21.41.61.82.0

A mark that draws a size as the width of a stroke rather than the radius of a circle is explained by strokes of that thickness. No built-in mark draws one, so seeing that swatch means registering a layer type that declares {:size :width}.

One legend per aesthetic is also the reason an aesthetic has one scale. A panel has one of each axis, so two layers naming different scales for :x are refused; a plot has one legend per appearance aesthetic, so two layers reading :size through different scales are refused in the same way.

On an axis, a layer naming no scale is no disagreement – it is drawn against whichever scale the panel has. On an appearance aesthetic the default counts as a scale, so a layer that names none brings :linear to the comparison and disagrees with a layer that names :log. Name the same scale in each mapping, or set it once with pj/scale, which is what the message says.

(try
  (-> gapminder-2007
      (pj/pose :gdp-percap :life-exp)
      (pj/lay-point {:size {:column :pop :scale :log}})
      (pj/lay-point {:size {:column :pop :scale :linear}})
      pj/plan)
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"Layers read :size through different scales: {:type :log}, {:type :linear}. One legend explains one scale, so the plot cannot say which of the two a mark's size means. Set the scale once with (pj/scale pose :size ...), or write the same :scale in each mapping."

The same two layers with the second naming nothing, which is the case the two aesthetics answer differently:

(try
  (-> gapminder-2007
      (pj/pose :gdp-percap :life-exp {:size :pop})
      (pj/lay-point {:size {:column :pop :scale :log}})
      (pj/lay-point {})
      pj/plan)
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"Layers read :size through different scales: {:type :log}, {:type :linear}. One legend explains one scale, so the plot cannot say which of the two a mark's size means. Set the scale once with (pj/scale pose :size ...), or write the same :scale in each mapping."

Not supported yet

  • Two layers of one pose cannot read an aesthetic through different scales, as just shown. Lifting this would need a plot to carry two legends for one aesthetic.

  • Facet panels share their scale types. Their domains can already differ: {:scales :free} gives each panel its own.

  • A :size mark is scaled against its own panel, while the legend is scaled against the whole plot. Two facet panels whose values cover different intervals can therefore look the same. Setting :domain explicitly avoids this.

See Also

source: notebooks/plotje_book/scales.clj