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}))A tall, narrow plot.
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
(pj/options {:width 300 :height 500}))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)"}))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)"}))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)"}))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)"}))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)"})):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)"}))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"}))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}))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}))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}))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 ","}))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 "."}))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 ","}))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 ","}))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"}))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})):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})):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})):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})):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}))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]}))Alpha works on bars and polygons too.
(-> (rdatasets/datasets-iris)
(pj/lay-bar :species {:alpha 0.4}))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β:normalor:bold(default:normal):font-styleβ:normalor: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"]}}))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"]}}))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"]}}))(-> {:x [1] :y [1]}
(pj/lay-text :x :y {:text :tag :box true
:data {:x [1] :y [1] :tag ["a boxed label"]}}))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"]}})){: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"]}}))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}))Shaded bands draw at a default opacity of 0.15:
(:band-opacity (pj/config))0.15Pass {: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}))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}))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}))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}}))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}))Legend on top:
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
(pj/options {:legend-position :top}))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}))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
:xand:y, andpj/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