19  Customization

How to adjust the look of a plot: dimensions, labels, scales, mark styling, palettes, themes, and legend placement. Where a mark goes, as opposed to how it looks, is Placing Marks.

Other appearance topics live in their natural homes: column-to-aesthetic mapping in Core Concepts, reference lines and bands in Core Concepts (constant positions) and Timelines (temporal intercepts), and tooltips/brushing in Interactivity.

(ns plotje-book.customization
  (:require
   ;; Kindly -- notebook rendering protocol
   [scicloj.kindly.v4.kind :as kind]
   ;; Plotje -- composable plotting
   [scicloj.plotje.api :as pj]
   ;; Rdatasets -- standard datasets
   [scicloj.metamorph.ml.rdatasets :as rdatasets]
   ;; Clojure2d -- palette and gradient discovery
   [clojure2d.color :as c2d]))

Dimensions

A wide, short plot.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:width 800 :height 250}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

A tall, narrow plot.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:width 300 :height 500}))
sepal widthsepal lengthspeciessetosaversicolorvirginica682.02.22.42.62.83.03.23.43.63.84.04.24.4

Titles and Labels

Override axis labels and add a title.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:title "Iris Sepal Measurements"
                 :x-label "Length (cm)"
                 :y-label "Width (cm)"}))
Iris Sepal MeasurementsWidth (cm)Length (cm)speciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Add a subtitle and caption for context.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:title "Iris Measurements"
                 :subtitle "Sepal dimensions across three species"
                 :caption "Source: Fisher's Iris dataset (1936)"}))
Iris MeasurementsSepal dimensions across three speciessepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5Source: Fisher's Iris dataset (1936)

Legend titles default to the column name. Override with :color-label, :size-label, :alpha-label, or :shape-label:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:color-label "Species (override)"}))
sepal widthsepal lengthSpecies (override)setosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

The size legend title comes from :size-label:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:size :petal-length})
    (pj/options {:size-label "Petal length (override)"}))
sepal widthsepal lengthPetal length (override)1234564.55.05.56.06.57.07.58.02.02.53.03.54.04.5

And :alpha-label overrides the alpha legend title:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:alpha :petal-length})
    (pj/options {:alpha-label "Petal length (override)"}))
sepal widthsepal lengthPetal length (override)1234564.55.05.56.06.57.07.58.02.02.53.03.54.04.5

:shape-label does the same for the shape legend. Naming it also splits a merged color-and-shape legend back into two, since asking for a separate name is asking for a separate legend:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species :shape :species})
    (pj/options {:shape-label "Marker (override)"}))
sepal widthsepal lengthspeciessetosaversicolorvirginicaMarker (override)setosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Color and fill

Most marks expose :color as the encoding channel – scatter dots, lines, bar interiors, area fills, violins, lollipops – all styled with :color and named via :color-label in the legend. The separate :fill channel is currently reserved for the heatmap family: lay-tile (and the :bin2d output beneath lay-density-2d) reads the encoded value as a continuous fill, with its own legend title override :fill-label:

(-> {:x [1 2 3 1 2 3] :y [1 1 1 2 2 2] :z [10 20 30 40 50 60]}
    (pj/lay-tile :x :y {:fill :z})
    (pj/options {:fill-label "Score"}))
yxScore10.0060.001.01.21.41.61.82.02.22.42.62.83.01.01.11.21.31.41.51.61.71.81.92.0

Coming from ggplot2. ggplot’s colour= (stroke) and fill= (interior) split is partial in Plotje today. On filled marks like lay-bar, lay-area, and lay-violin, the :color aesthetic paints the interior; there is no separate stroke channel, and :fill is not accepted. A lay-bar styled with {:color :species} produces one filled polygon per category:

(-> (rdatasets/datasets-iris)
    (pj/lay-bar :species {:color :species}))
speciesspeciessetosaversicolorvirginicasetosaversicolorvirginica05101520253035404550

Rotating tick labels

When a categorical x-axis has many categories, or long category names, the tick labels run into each other and become hard to read. Rotate them with :x-tick-angle, given in degrees. A value of -45 is a common diagonal that keeps the text legible while saving horizontal room.

(-> {:product (map #(str "Product " %) (range 12))
     :revenue [120 95 140 60 175 80 110 150 90 130 70 160]}
    (pj/lay-bar :product :revenue)
    (pj/options {:x-tick-angle -45}))
revenueproductProduct 0Product 1Product 2Product 3Product 4Product 5Product 6Product 7Product 8Product 9Product 10Product 11020406080100120140160180

Plotje reserves extra vertical space below the panel for the angled labels, scaled by the angle. When that automatic estimate reserves too much or too little, set :x-tick-label-pad (in drawing units) to control the reserved height directly:

(-> {:product (map #(str "Product " %) (range 12))
     :revenue [120 95 140 60 175 80 110 150 90 130 70 160]}
    (pj/lay-bar :product :revenue)
    (pj/options {:x-tick-angle -45
                 :x-tick-label-pad 90}))
revenueproductProduct 0Product 1Product 2Product 3Product 4Product 5Product 6Product 7Product 8Product 9Product 10Product 11020406080100120140160180

A label rotated this way extends down and to the left of its tick. Very long names can run past the left edge of the plotting area; see Known Limitations.

Grouping digits in large numbers

A count in the hundreds of thousands is hard to read as a run of digits: a reader has to count places to tell 462389 from 46238. :thousands-separator inserts a string between each group of three digits, in numeric tick labels and in the text that pj/lay-text and pj/lay-label take from a column.

It is off by default. Numbers are left as they are unless you ask, because grouping is wrong for a value that is an identifier rather than a quantity – a year axis would read 2,026.

(-> {:violation ["Meter Expired" "Over Time Limit" "Stop Prohibited"]
     :tickets   [462389 181444 163294]}
    (pj/lay-bar :tickets :violation)
    (pj/lay-label :tickets :violation {:text :tickets :align-x :right})
    (pj/options {:thousands-separator ","}))
violationtickets462,389181,444163,2940100,000200,000300,000400,000Meter ExpiredOver Time LimitStop Prohibited

The separator is whatever string you pass, so conventions other than the comma work too – a space, or the point used across much of Europe:

(-> {:violation ["Meter Expired" "Over Time Limit"]
     :tickets   [462389 181444]}
    (pj/lay-bar :tickets :violation)
    (pj/lay-label :tickets :violation {:text :tickets :align-x :right})
    (pj/options {:thousands-separator "."}))
violationtickets462.389181.4440100.000200.000300.000400.000Meter ExpiredOver Time Limit

Grouping widens the tick labels, and the space reserved for them grows to match, so a grouped axis does not push its labels into the panel. Here the same data drawn both ways gives a narrower panel once the separators appear:

(let [panel-width (fn [opts]
                    (-> {:x [1 2 3] :y [1000000 2000000 3000000]}
                        (pj/lay-point :x :y)
                        (pj/options opts)
                        pj/plan
                        :panel-width))]
  {:ungrouped (panel-width {})
   :grouped (panel-width {:thousands-separator ","})})
{:ungrouped 535.5, :grouped 524.5}

Only the digits to the left of the decimal point are grouped:

(-> {:x [1] :y [1] :amount [1234.56]}
    (pj/lay-label :x :y {:text :amount})
    (pj/options {:thousands-separator ","}))
yx1,234.560.00.20.40.60.81.01.21.41.61.82.00.00.20.40.60.81.01.21.41.61.82.0

What is grouped is what measures: tick labels on a numeric axis, label text read from a column, and the values a size or alpha legend prints beside its keys. What names something is left alone – category names, the labels of a colour or shape legend, and facet strip labels – for the reason the setting is off by default. A year on a numeric axis is a quantity and groups; the same year used as a category is a name, and still reads 2026 with a grouped axis beside it.

A year axis, grouped:

(-> (for [y (range 2020 2031)] {:year y :revenue (* 1000 (- y 2019))})
    (pj/lay-point :year :revenue)
    (pj/options {:thousands-separator ","})
    pj/plan
    :panels
    first
    :x-ticks
    :labels)
["2,020"
 "2,021"
 "2,022"
 "2,023"
 "2,024"
 "2,025"
 "2,026"
 "2,027"
 "2,028"
 "2,029"
 "2,030"]

The same years as categories, left alone:

(-> (for [y (range 2020 2024)] {:year y :revenue (* 1000 (- y 2019))})
    (pj/lay-bar :year :revenue {:x-type :categorical})
    (pj/options {:thousands-separator ","})
    pj/plan
    :panels
    first
    :x-ticks
    :labels)
["2020" "2021" "2022" "2023"]

A size legend groups its values, so it reads the same way as the axis beside it, while the colour legend’s category names do not:

(->> (-> (for [i (range 8)] {:xx (double i) :yy (double i)
                             :volume (* 100000 (inc i)) :region (str "region " i)})
         (pj/lay-point :xx :yy {:size :volume :color :region})
         (pj/options {:thousands-separator ","})
         pj/svg-summary
         :texts)
     (filter #(re-find #"," %))
     distinct
     sort)
("100,000"
 "200,000"
 "300,000"
 "400,000"
 "500,000"
 "600,000"
 "700,000"
 "800,000")

Scales

Use a log scale for data spanning orders of magnitude.

(def exponential-data
  {:x (range 1 50)
   :y (map #(* 2 (Math/pow 1.1 %)) (range 1 50))})

Linear scale – hard to see the structure.

(-> exponential-data
    (pj/lay-point :x :y)
    (pj/options {:title "Linear Scale"}))
Linear Scaleyx05101520253035404550050100150200

Log y-scale – reveals the exponential trend.

(-> exponential-data
    (pj/lay-point :x :y)
    (pj/scale :y :log)
    (pj/options {:title "Log Y Scale"}))
Log Y Scaleyx05101520253035404550110100

Lock the y-axis to a specific range.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/scale :y {:type :linear :domain [0 6]})
    (pj/options {:title "Fixed Y Domain [0, 6]"}))
Fixed Y Domain [0, 6]sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.00123456

A domain narrower than the data turns the axis into a view window: marks falling outside it are clipped to the panel, and the underlying data is kept. This matches ggplot2’s coord_cartesian, which zooms the view, rather than scale limits, which drop rows. Here the sepal-width domain is tightened to [3.0, 3.5], so points above and below that band are clipped at the panel edge. All 150 observations are still rendered – the marks sit behind a clip region, one per panel.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/scale :y {:type :linear :domain [3.0 3.5]})
    (pj/options {:title "Tight Y Domain [3.0, 3.5]"}))
Tight Y Domain [3.0, 3.5]sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.03.03.13.23.33.43.5

Pin exact tick locations with :breaks (ggplot2’s scale_*_continuous(breaks=...)).

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/scale :y {:type :linear :breaks [2.0 3.0 4.0]}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.0234

Pair :breaks with :labels to render numeric positions with custom tick text. The two vectors must match in count – each label is shown at its corresponding break. This is the path for cases like a tile heatmap where the axis is numerically indexed (1-7) but the natural labels are categorical (days of the week).

(-> (for [day (range 1 8) hour (range 0 24)]
      {:day day :hour hour :load (+ (* 0.3 (Math/sin (* 0.5 hour)))
                                    (* 0.2 (mod day 3)))})
    (pj/lay-tile :day :hour {:fill :load})
    (pj/scale :x {:type :linear
                  :breaks [1 2 3 4 5 6 7]
                  :labels ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]})
    (pj/options {:title "Weekly Load by Hour"}))
Weekly Load by Hourhourdayfill-0.30000.6992MonTueWedThuFriSatSun05101520

Order a categorical axis explicitly with :type :categorical and a :domain vector. Without this, categories appear in their order of first occurrence in the data.

(-> {:size ["medium" "small" "large"]
     :count [12 30 7]}
    (pj/lay-bar :size :count)
    (pj/scale :x {:type :categorical :domain ["large" "medium" "small"]}))
countsizelargemediumsmall051015202530

On a categorical axis, :breaks selects which categories get a tick, and :labels relabels them – the discrete counterpart to numeric :breaks above. Each break is matched to a category by its displayed label; a break that names no category is dropped with a warning. Here only two of the four quarters are ticked, with custom text:

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

Thin a crowded categorical axis with :n-ticks. A categorical axis labels every category by default, so with many categories the labels overlap. :n-ticks keeps roughly that many evenly-spaced tick labels instead. (When both are given, explicit :breaks win over :n-ticks. Rotating the labels with :x-tick-angle is the other way to handle crowding – see Rotating tick labels above.)

(-> {: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

Log scale on visual channels

pj/scale works on continuous visual channels too – :size, :alpha, :fill, and :color. When the encoded column spans many orders of magnitude, a log scale spaces the legend ticks logarithmically and maps the visual property (radius, alpha, gradient color) in log-space, so each tick step represents the same multiplicative ratio. :categorical does not apply to a continuous encoding – visual channels accept :linear (the default) and :log only.

Point sizes from a column whose values jump by factors of ten. Without :scale :size :log, the default linear mapping puts the n=10 and n=100 points at nearly the same radius – only n=1000 stands out. Linear scaling reflects absolute distance, which is dominated by the largest value:

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

With pj/scale :size :log, each factor-of-10 step reflects the same proportional jump in radius, so the n=10 and n=100 points are now visibly distinct:

(-> {:user [:a :b :c] :n [10 100 1000]}
    (pj/lay-point :user :n {:size :n :x-type :categorical})
    (pj/scale :size :log))
nusern101001000abc01002003004005006007008009001000

The size legend’s tick values are the original numbers (10, 100, 1000), but the dot radii grow in log-space – each step reflects the same factor, matching what you see at the same data values in the plot.

Tile heatmap with log-scaled fill:

(-> (for [r (range 5) c (range 5)]
      {:r r :c c :v (Math/pow 10.0 (/ (+ r c) 2.0))})
    (pj/lay-tile :r :c {:fill :v})
    (pj/scale :fill :log))
crfill1101001000100000.00.51.01.52.02.53.03.54.00.00.51.01.52.02.53.03.54.0

The continuous fill legend draws log-spaced tick labels along the gradient bar so a tile’s color reads as its log-space position between the data minimum and maximum.

To override the inferred type of a column (e.g. force a numeric :hour column to render as categorical bands), see Inference Rules.

Shape symbols

:shape is a discrete channel, so its scale controls two things a continuous channel has no use for: which order the categories are assigned symbols in, and which symbols those are. Both matter when a reader compares two plots – the same category should keep the same marker across them.

pj/shape-symbols lists the available markers, in the order they are assigned to categories:

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; it warns when that happens.

Left alone, the categories take those symbols in the order they appear in the data:

(-> {:model ["a" "b" "c" "d"] :score [3 1 4 2] :tier ["gold" "silver" "bronze" "gold"]}
    (pj/lay-point :model :score {:shape :tier}))
scoremodeltiergoldsilverbronzeabcd1.01.52.02.53.03.54.0

:domain sets the category order, which is also the legend order:

(-> {:model ["a" "b" "c" "d"] :score [3 1 4 2] :tier ["gold" "silver" "bronze" "gold"]}
    (pj/lay-point :model :score {:shape :tier})
    (pj/scale :shape {:domain ["gold" "silver" "bronze"]}))
scoremodeltiergoldsilverbronzeabcd1.01.52.02.53.03.54.0

:values picks the symbols themselves, paired with the categories in that same order:

(-> {:model ["a" "b" "c" "d"] :score [3 1 4 2] :tier ["gold" "silver" "bronze" "gold"]}
    (pj/lay-point :model :score {:shape :tier})
    (pj/scale :shape {:domain ["gold" "silver" "bronze"]
                      :values [:diamond :cross :plus]}))
scoremodeltiergoldsilverbronzeabcd1.01.52.02.53.03.54.0

Mark Styling

Pass :alpha and :size directly to layer functions.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species :alpha 0.5 :size 5}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

:size controls line thickness on line-based marks:

(-> {:x [1 2 3 4 5] :y [2 4 3 5 4]}
    (pj/lay-line :x :y {:size 3}))
yx1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

:stroke-dash draws a line dashed or dotted, so a projected or reference series reads apart from measured data. Pass a named preset or a raw [dash gap] pattern in drawing units.

:dashed:

(-> {:x [1 2 3 4 5] :y [2 4 3 5 4]}
    (pj/lay-line :x :y {:stroke-dash :dashed}))
yx1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

:dotted – a shorter dash and gap:

(-> {:x [1 2 3 4 5] :y [2 4 3 5 4]}
    (pj/lay-line :x :y {:stroke-dash :dotted}))
yx1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

:solid is the default – an unbroken line, so no dash pattern:

(-> {:x [1 2 3 4 5] :y [2 4 3 5 4]}
    (pj/lay-line :x :y {:stroke-dash :solid}))
yx1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

A raw [dash gap] vector sets the pattern directly, in drawing units – here a long dash and a short gap:

(-> {:x [1 2 3 4 5] :y [2 4 3 5 4]}
    (pj/lay-line :x :y {:stroke-dash [12 4]}))
yx1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

Alpha works on bars and polygons too.

(-> (rdatasets/datasets-iris)
    (pj/lay-bar :species {:alpha 0.4}))
speciessetosaversicolorvirginica05101520253035404550

Text Placement

Where a text mark goes – anchoring it to its point, shifting it by a distance on the page, placing it at a value rather than a column, or on the panel rather than in the data – is the subject of Placing Marks. The rest of this chapter covers how text looks once it is placed.

Bold and Italic Text

A label placed on top of the data has to be read against it. Where :align-x and :align-y move the text, two further options change how it is drawn:

  • :font-weight – :normal or :bold (default :normal)
  • :font-style – :normal or :italic (default :normal)

The two are independent, so a label can be both bold and italic. Both apply to pj/lay-text and pj/lay-label, and to both output formats: the SVG backend writes them as font attributes and the PNG backend draws with the matching Java font style.

Bold picks one label out of several. Here the peak is emphasized and the two ordinary points are left plain:

(-> {:x [1 2 3] :y [2 3 1]}
    (pj/lay-point :x :y {:size 5 :color "#888888"})
    (pj/lay-text :x :y {:text :tag :align-x :center :align-y :bottom
                        :data {:x [1 3] :y [2 1] :tag ["steady" "dip"]}})
    (pj/lay-text :x :y {:text :tag :align-x :center :align-y :bottom
                        :font-weight :bold
                        :data {:x [2] :y [3] :tag ["peak"]}}))
yxsteadydippeak1.01.21.41.61.82.02.22.42.62.83.01.01.21.41.61.82.02.22.42.62.83.0

Italic reads as an aside – a remark about the data rather than a value taken from it. On pj/lay-label it sits in the same background box as any other label text:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species :alpha 0.5})
    (pj/lay-label {:text :note :font-style :italic
                   :data {:sepal-length [7.0] :sepal-width [4.2]
                          :note ["setosa sits apart"]}}))
sepal widthsepal lengthspeciessetosaversicolorvirginicasetosa sits apart4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Text on a Background Box

Text placed over dense data competes with the marks underneath. A background box separates the two: :box draws the text on a white panel with rounded corners and a thin border.

pj/lay-label is the same layer type with the box switched on, so every option in this section applies to both. These two produce the same plot:

(-> {:x [1] :y [1]}
    (pj/lay-label :x :y {:text :tag :data {:x [1] :y [1] :tag ["a boxed label"]}}))
yxa boxed label0.00.20.40.60.81.01.21.41.61.82.00.00.20.40.60.81.01.21.41.61.82.0
(-> {:x [1] :y [1]}
    (pj/lay-text :x :y {:text :tag :box true
                        :data {:x [1] :y [1] :tag ["a boxed label"]}}))
yxa boxed label0.00.20.40.60.81.01.21.41.61.82.00.00.20.40.60.81.01.21.41.61.82.0

Pass a map to shape the box. :corner-radius is how round the corners are, in drawing units – three labels at decreasing radius, the last square.

A box sits at its data point, so it would cover the very point it labels. :nudge-x shifts each label clear of its point, in data units – the same idiom a scatter plot needs when labelling its marks:

(-> {:x [1 1 1] :y [3 2 1]}
    (pj/lay-point :x :y {:size 5 :color "#888888"})
    (pj/lay-label :x :y {:text :tag :box {:corner-radius 8} :nudge-x 0.05
                         :data {:x [1] :y [3] :tag ["corner-radius 8"]}})
    (pj/lay-label :x :y {:text :tag :nudge-x 0.05
                         :data {:x [1] :y [2] :tag ["the default, 3"]}})
    (pj/lay-label :x :y {:text :tag :box {:corner-radius 0} :nudge-x 0.05
                         :data {:x [1] :y [1] :tag ["corner-radius 0"]}}))
yxcorner-radius 8the default, 3corner-radius 00.00.20.40.60.81.01.21.41.61.82.01.01.21.41.61.82.02.22.42.62.83.0

{:box false} on pj/lay-label leaves the text bare, the same as calling pj/lay-text:

(-> {:x [1] :y [1]}
    (pj/lay-label :x :y {:text :tag :box false
                         :data {:x [1] :y [1] :tag ["bare text"]}}))
yxbare text0.00.20.40.60.81.01.21.41.61.82.00.00.20.40.60.81.01.21.41.61.82.0

Reference Line and Band Appearance

Reference lines and bands are introduced in Core Concepts; on temporal axes, intercepts can be LocalDate / Instant values – see Timelines. This section covers the appearance defaults you can override.

They take :offset-x and :offset-y like any other layer, so a rule can sit a fixed distance from the value it marks – a line drawn just above a threshold rather than on it. :in is the one layer option they do not take: their positions come from data values.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:alpha 0.4})
    (pj/lay-rule-h {:y-intercept 3.0 :color "#cc3311"})
    (pj/lay-rule-h {:y-intercept 3.0 :color "#4477aa" :offset-y -25}))
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Shaded bands draw at a default opacity of 0.15:

(:band-opacity (pj/config))
0.15

Pass {:alpha ...} on a band layer to override:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/lay-band-v {:x-min 5.5 :x-max 6.5})
    (pj/lay-band-h {:y-min 3.0 :y-max 3.5 :alpha 0.3}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Note: intercept and band-edge positions must be literal values (numbers, or temporal values on a time axis) in this release. A faceted plot with a different reference value per panel (column-mapped intercept, ggplot2’s geom_hline(aes(yintercept=...))) is on the post-alpha roadmap. Today, an annotation added once with the same intercept appears on every panel of the faceted pose.

Giving a line layer its own two-point dataset does not stand in for it: a layer’s own :data is not split by pj/facet either, so each panel draws every row of it. To vary a reference value across panels today, build them with pj/arrange – each cell is its own pose, and takes its own intercept.

Reference lines accept :stroke-dash too, so a threshold or target line can read as dashed or dotted rather than solid:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/lay-rule-v {:x-intercept 6.0 :color "gray" :stroke-dash :dashed}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Palettes

Pass :palette to override the default color cycle. It accepts a vector of hex strings, a map from category to hex, or a keyword naming one of the built-in palettes (:set1, :set2, :dark2, :tableau-10, :category10, :pastel1, :accent, :paired, and many more).

The full list of forms is in Palette Configuration, the project-level / thread-local / plot-level precedence chain in The Precedence Chain, and the key table in Configuration Keys.

Custom vector:

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

Named preset – here :dark2 for a high-contrast qualitative palette:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:palette :dark2}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Discovering Palettes and Gradients

Plotje delegates color to the clojure2d library, which bundles thousands of named palettes and gradients. Use clojure2d.color/find-palette and clojure2d.color/find-gradient to search by regex pattern.

Find palettes whose name contains β€œbudapest”.

(c2d/find-palette #"budapest")
(:grand-budapest-1 :grand-budapest-2)

Find palettes whose name contains β€œset”.

(c2d/find-palette #"^:set")
(:set1 :set2 :set3)

Find gradients related to β€œviridis”.

(c2d/find-gradient #"viridis")
(:mpl/viridis
 :viridis/cividis
 :viridis/inferno
 :viridis/magma
 :viridis/mako
 :viridis/plasma
 :viridis/rocket
 :viridis/turbo
 :viridis/viridis)

c2d/palette returns the colors for a given name. Each color is a clojure2d Vec4 (RGBA, 0-255 range).

(c2d/palette :grand-budapest-1)
[[241.0 187.0 123.0 255.0]
 [253.0 100.0 103.0 255.0]
 [91.0 26.0 24.0 255.0]
 [214.0 114.0 54.0 255.0]]

Colorblind-friendly palettes

For presentations and publications, consider palettes designed for colorblind readers. Several good options are built in:

  • :set2 – muted qualitative, 8 colors
  • :dark2 – dark qualitative, 8 colors
  • :khroma/okabeito – designed specifically for color vision deficiency
  • :tableau-10 – Tableau default, high contrast
(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:palette :khroma/okabeito}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Theme

Customize background color, grid color, and font size.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:title "White Theme"
                 :theme {:bg "#FFFFFF" :grid "#EEEEEE" :font-size 10}}))
White Themesepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Legend Position

Control where the legend appears: :right (default), :bottom, :top, or :none.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:legend-position :bottom}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Legend on top:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:legend-position :top}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

No legend at all – useful when the color encoding is documented in the title or caption rather than a separate legend. The panel takes the full width since no legend strip is reserved:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/options {:legend-position :none}))
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

See Also

  • Core Concepts – the mapping and aesthetic vocabulary used throughout this chapter
  • Options and Scopes – where layer options, plot options, and configuration live
  • Placing Marks – where a mark goes: anchoring, offsets, values for :x and :y, and pj/frames
  • Interactivity – tooltips and brush selection

What’s Next

  • Placing Marks – where a mark goes, and in what units
  • Faceting – split any chart into panels by one or two variables
  • API Reference – complete function listing with docstrings
source: notebooks/plotje_book/customization.clj