22  Customization

How to adjust the look of a plot: dimensions, labels, tick text, mark styling, themes, and legend placement. Where a mark goes, as opposed to how it looks, is Placing Marks. What a scale is and what each aesthetic’s scale takes is Scales.

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. Each is the outermost scope of the aesthetic’s own :label, which titles one mapping or one layer and wins where both are written – Scales has that spelling:

(-> (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)12345656782.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)12345656782.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 aesthetic – scatter dots, lines, bar interiors, area fills, violins, lollipops – all styled with :color and named via :color-label in the legend. The separate :fill aesthetic 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"}))
yxScore106012312

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 aesthetic, 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.56012012

Grouping applies to the numbers that measure something: tick labels on a numeric axis, the text pj/lay-text and pj/lay-label take from a column, and the values a size or alpha legend prints. Category names, color and shape legend labels, and facet strip labels are left alone, because those name a group rather than measure it.

A year falls on either side of that, depending on how it is used. Plotted on a numeric axis it is a quantity, so it groups:

(-> (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"]

Used as categories, the same years name four groups, so they are left alone – even with a grouped axis beside them:

(-> (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 color 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")

Writing the decimal point

Some cultures write the decimal point as a comma: 1234,5 rather than 1234.5. :decimal-separator names the string to draw in that place, in the same text :thousands-separator groups. It is off by default too. The two usually go together – where the point groups the digits, the comma separates the fraction, giving 1.234,5.

(-> {:region ["North" "South" "East"]
     :profit [1234.5 1500.25 2680.75]}
    (pj/lay-bar :profit :region)
    (pj/lay-label :profit :region {:text :profit :align-x :right})
    (pj/options {:thousands-separator "." :decimal-separator ","}))
regionprofit1.234,51.500,252.680,7505001.0001.5002.0002.500NorthSouthEast

Each bar is labelled with its own value, so 1.234,5 shows both separators in one number. The ticks below land on round hundreds and have no decimal part to write, so they show only the grouping.

Tick placement and text

An axis scale carries the options that place and word its tick marks. What each of those keys means, and everything else a scale does, is in Scales. The recipe here is the case where the axis is indexed by number and the labels should be words.

:breaks pins the values that get a tick, and :tick-labels gives each one its text – one label per break. This tile heatmap is indexed by day number, 1 to 7, and its ticks read as days:

(-> (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 {:breaks [1 2 3 4 5 6 7]
                  :tick-labels ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]})
    (pj/options {:title "Weekly Load by Hour"}))
Weekly Load by Hourhourdayfill-0.2999970.699248MonTueWedThuFriSatSun05101520

Crowded tick labels have two other answers: rotate them, which the Rotating tick labels section above covers, or thin them to a chosen count with :n-ticks, which Scales covers. How the digits within a tick label are written is set separately, in the two sections on grouping digits and on the decimal point above.

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}))
yx123452345

: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}))
yx123452345

: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}))
yx123452345

: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}))
yx123452345

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]}))
yx123452345

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"]}}))
yxsteadydippeak123123

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 label012012
(-> {:x [1] :y [1]}
    (pj/lay-text :x :y {:text :tag :box true
                        :data {:x [1] :y [1] :tag ["a boxed label"]}}))
yxa boxed label012012

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 0012123

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

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 written 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

Discovering Palettes and Gradients

A :color or :fill scale spans a palette or a gradient, named with the :color-values and :color-range plot options – Scales covers what they do. This section is the catalogue of what there is to name.

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 {:color-values :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
  • Scales – types, domains, ranges, and the scale spec each aesthetic reads
  • 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