7  Composition

Plotje’s poses let you combine whole plots into a single rendered image. A composite pose holds other poses and a layout; each sub-pose renders independently and the composite tiles them together.

This chapter walks through composition patterns from simple side-by-side arrangements to shared-scale marginal plots, using pj/arrange, pj/marginal and explicit composite-pose maps – compactly for simple cases, with a bit of literal map construction for nested layouts.

(ns plotje-book.composition
  (:require
   ;; Tablecloth -- dataset manipulation
   [tablecloth.api :as tc]
   ;; 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]))

Side-by-Side via pj/arrange

The simplest composite: two independent poses, placed next to each other. pj/arrange takes a vector of poses and returns a composite. Each sub-pose has its own data, mapping, layers, and options. Coming from R, this is the same shape as patchwork’s p1 | p2 operator or cowplot’s plot_grid(p1, p2).

(pj/arrange
 [(-> (rdatasets/datasets-iris) (pj/lay-point :sepal-length :sepal-width {:color :species}))
  (-> (rdatasets/datasets-iris) (pj/lay-point :petal-length :petal-width {:color :species}))])
sepal widthsepal length682.02.53.03.54.04.5petal widthpetal length50.00.51.01.52.02.5speciessetosaversicolorvirginica

Pass {:cols 1} for a stacked arrangement (one column means each pose goes on its own row):

(pj/arrange
 [(-> (rdatasets/datasets-iris) (pj/lay-point :sepal-length :sepal-width {:color :species}))
  (-> (rdatasets/datasets-iris) (pj/lay-point :petal-length :petal-width {:color :species}))]
 {:cols 1})
sepal widthsepal length4.55.05.56.06.57.07.58.0234petal widthpetal length1234567012speciessetosaversicolorvirginica

pj/arrange divides space equally among its sub-poses. For unequal splits (e.g., give the first panel twice the space of the second), construct the composite as an explicit map; the next section shows how.

Explicit Composite Poses

Under pj/arrange there is a plain-map composite pose. You can construct one directly when you need finer control – unequal weights, shared scales, or (in future work) non-plot leaves like text panels and key performance indicators.

An explicit :layout accepts :direction (:horizontal or :vertical) and :weights (one weight per sub-pose). Here the first panel gets twice the space of the second:

(def weighted
  (pj/pose
   {:layout {:direction :horizontal :weights [2 1]}
    :poses [{:mapping {:x :sepal-length :y :sepal-width}
             :layers [{:layer-type :point}]}
            {:mapping {:x :petal-length :y :petal-width}
             :layers [{:layer-type :point}]}]
    :data (rdatasets/datasets-iris)}))

pj/pose accepts a literal composite map and tags it for notebook auto-render – the same pose value the threaded API produces:

weighted
sepal widthsepal length56782.02.53.03.54.04.5petal widthpetal length50.00.51.01.52.02.5

And printed, showing the composite’s structure – :layout with direction and weights at the top, then each sub-pose with its own :mapping and :layers, and the outer :data dataset:

(kind/pprint weighted)
{:layout {:direction :horizontal, :weights [2 1]},
 :poses
 [{:mapping {:x :sepal-length, :y :sepal-width},
   :layers [{:layer-type :point}]}
  {:mapping {:x :petal-length, :y :petal-width},
   :layers [{:layer-type :point}]}],
 :data
 https://vincentarelbundock.github.io/Rdatasets/csv/datasets/iris.csv [150 6]:

| :rownames | :sepal-length | :sepal-width | :petal-length | :petal-width |  :species |
|----------:|--------------:|-------------:|--------------:|-------------:|-----------|
|         1 |           5.1 |          3.5 |           1.4 |          0.2 |    setosa |
|         2 |           4.9 |          3.0 |           1.4 |          0.2 |    setosa |
|         3 |           4.7 |          3.2 |           1.3 |          0.2 |    setosa |
|         4 |           4.6 |          3.1 |           1.5 |          0.2 |    setosa |
|         5 |           5.0 |          3.6 |           1.4 |          0.2 |    setosa |
|         6 |           5.4 |          3.9 |           1.7 |          0.4 |    setosa |
|         7 |           4.6 |          3.4 |           1.4 |          0.3 |    setosa |
|         8 |           5.0 |          3.4 |           1.5 |          0.2 |    setosa |
|         9 |           4.4 |          2.9 |           1.4 |          0.2 |    setosa |
|        10 |           4.9 |          3.1 |           1.5 |          0.1 |    setosa |
|       ... |           ... |          ... |           ... |          ... |       ... |
|       140 |           6.9 |          3.1 |           5.4 |          2.1 | virginica |
|       141 |           6.7 |          3.1 |           5.6 |          2.4 | virginica |
|       142 |           6.9 |          3.1 |           5.1 |          2.3 | virginica |
|       143 |           5.8 |          2.7 |           5.1 |          1.9 | virginica |
|       144 |           6.8 |          3.2 |           5.9 |          2.3 | virginica |
|       145 |           6.7 |          3.3 |           5.7 |          2.5 | virginica |
|       146 |           6.7 |          3.0 |           5.2 |          2.3 | virginica |
|       147 |           6.3 |          2.5 |           5.0 |          1.9 | virginica |
|       148 |           6.5 |          3.0 |           5.2 |          2.0 | virginica |
|       149 |           6.2 |          3.4 |           5.4 |          2.3 | virginica |
|       150 |           5.9 |          3.0 |           5.1 |          1.8 | virginica |
}

The outer :data is inherited by both sub-poses. Each sub-pose has its own :mapping and :layers, and need not repeat the dataset. Subsequent examples in this chapter follow the same shape and show only the rendered plot.

Shared Scales

By default, sibling poses in a composite compute their own domains. That is fine when their columns differ, but for the same column shown twice (e.g., a marginal above a scatter, or a mosaic of scatters all measuring the same variable) you want the axes aligned. :share-scales pins scales across siblings by effective column:

(def shared-x
  (pj/pose
   {:share-scales #{:x}
    :layout {:direction :horizontal :weights [1 1]}
    :poses [{:mapping {:x :sepal-length :y :sepal-width}
             :layers [{:layer-type :point}]}
            {:mapping {:x :sepal-length :y :petal-length}
             :layers [{:layer-type :point}]}]
    :data (rdatasets/datasets-iris)}))
shared-x
sepal widthsepal length56782.02.53.03.54.04.5petal lengthsepal length56781234567

A date column pools the same way – an axis holds a date as a number of milliseconds, so the union across cells is defined, and the cells are ticked over it rather than each over its own dates. See Change Over Time.

Both panels share the sepal-length x-domain even though their y columns differ. Column bucketing is automatic: only siblings whose effective x-column matches share a scale. Panels with different x-columns would each get their own domain.

A rule or a band is covered too. A shared axis pools the columns its cells name, and a rule names none: its :x-intercept is a mapping like any other, but it takes a written value rather than a column reference. The shared domain covers those written values too. Here one cell carries a limit beyond anything either species measures, and both cells reach it:

(def limit 8.5)
(pj/arrange
 [(-> (rdatasets/datasets-iris)
      (tc/select-rows #(= "setosa" (:species %)))
      (pj/lay-point :sepal-length :sepal-width)
      (pj/lay-rule-v {:x-intercept limit :color "firebrick"}))
  (-> (rdatasets/datasets-iris)
      (tc/select-rows #(= "virginica" (:species %)))
      (pj/lay-point :sepal-length :sepal-width))]
 {:share-scales #{:x}})
sepal widthsepal length682.22.42.62.83.03.23.43.63.84.04.24.4sepal widthsepal length682.22.42.62.83.03.23.43.63.8

Cells That Line Up

Sharing a scale is not enough on its own. Each cell reserves the room its own y labels need, so a cell labelled in single digits comes out wider than a cell labelled in millions, and the axis the two share covers a different extent in each. :align-panels reserves the widest y-label pad and legend column any cell needs on all of them, so every cell gets the same drawing area:

(def readings
  {:t [1 2 3 4 5]
   :rate [1.0 2.0 3.0 2.0 4.0]
   :total [1200000.0 2400000.0 1800000.0 3100000.0 2600000.0]})
(pj/arrange
 [(-> readings (pj/lay-line :t :rate))
  (-> readings (pj/lay-line :t :total))]
 {:cols 1 :share-scales #{:x} :align-panels true})
ratet123451234totalt123451500000200000025000003000000

Pair it with :share-scales where the cells are meant to be read against one another. pj/marginal sets both for you – the next section takes one apart.

Marginal Plots

The classic “scatter with top density” – a distribution strip beside the main plot – has a function of its own. pj/marginal takes a leaf pose and puts a distribution of one of its columns in a thin panel against one edge: the :x column above the panel, the :y column to its right.

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

The strip shares the scatter’s x axis and keeps its own value scale. Its duplicate x ticks and axis title are dropped, since the axis below describes both panels, and the two drawing areas are aligned so a value sits at the same place in each – including here, where the scatter carries a legend and the strip does not.

The color mapping does not reach the strip. A marginal describes the pose’s :x column as a whole, so one grey curve is drawn over the three colored groups below it.

:histogram draws the distribution as bars instead of a curve, and :size sets the strip’s share of the height (0.25 by default):

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width)
    (pj/marginal :top :histogram {:size 0.35}))
020sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

A marginal on the right

:right describes the pose’s :y column instead, in a strip beside the panel. The distribution is drawn under pj/coord :flip, so its value axis runs across the strip and its baseline stands at the left edge, against the scatter:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/marginal :right))
sepal widthsepal lengthspeciessetosaversicolorvirginica56782.02.53.03.54.04.50.00.51.0

A :top marginal and a :right one line up on different edges. Two panels in a column share their left and right edges, so what has to agree is the room reserved for the y title and for the legend; two panels in a row share their top and bottom edges, so what has to agree is the room below them. :align-panels reserves whichever the layout calls for.

:bottom and :left are not drawn: a strip there would sit between the panel and the axis that describes it.

What a marginal is made of

A :top marginal is a vertical composite with a shared x, which can also be written out. Four things make it up: the weights, the shared scale, the ticks and axis title suppressed on the strip, and :align-panels. Here are the first three:

(def marginal-by-hand
  (pj/pose
   {:share-scales #{:x}
    :layout {:direction :vertical :weights [1 3]}
    :poses [{:mapping {:x :sepal-length}
             :opts {:suppress-x-ticks true :suppress-x-label true}
             :layers [{:layer-type :density}]}
            {:mapping {:x :sepal-length :y :sepal-width :color :species}
             :layers [{:layer-type :point}]}]
    :data (rdatasets/datasets-iris)}))
marginal-by-hand
0.00.20.4sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

So the two drawing areas do not line up: the strip draws no legend, reserves no column for one, and comes out wider than the scatter below it. The density then sits at a different scale from the points it describes.

That is the fourth part, and the one pj/marginal adds: :align-panels in the composite’s own :opts. It plans every cell twice, the second time with the widest pad any of them needed as a floor, so each cell reserves what the others do – the y-label pad and the legend column for a column of cells, the room below for a row of them. pj/arrange takes :align-panels among its options, as above, and a written-out composite carries it in its own :opts. pj/options does not accept it – it is a composite’s own setting rather than a plot option:

(assoc-in marginal-by-hand [:opts :align-panels] true)
0.00.20.4sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

With the key in place the written-out form is the composite pj/marginal returns, up to the weights it computes from :size. Nothing in the shape is specific to distributions, so the same four parts serve any stack of panels read against one x axis.

A Small Dashboard

Composite poses can combine heterogeneous chart types. Here is a dashboard-style 2x2 layout: a histogram of sepal length, a boxplot of sepal width by species, a scatter of petal dimensions, and a density of petal length.

Each cell is built as its own leaf pose, then pj/arrange takes rows of cells and produces the 2x2 grid:

(def dashboard
  (pj/arrange
   [[(-> (rdatasets/datasets-iris) (pj/lay-histogram :sepal-length))
     (-> (rdatasets/datasets-iris) (pj/lay-boxplot :species :sepal-width {:color :species}))]
    [(-> (rdatasets/datasets-iris) (pj/lay-point :petal-length :petal-width {:color :species}))
     (-> (rdatasets/datasets-iris) (pj/lay-density :petal-length {:color :species}))]]))
dashboard
sepal length567801020sepal widthspeciesspeciessetosaversicolorvirginicasetosaversicolorvirginica234petal widthpetal lengthspeciessetosaversicolorvirginica5012petal lengthspeciessetosaversicolorvirginica5012

Four panels, each its own layer type. pj/arrange stacks the rows vertically; each row is laid out horizontally. Every cell is a leaf pose – pj/arrange does not accept composite cells.

Drawing Layers with Different Data

A layer can carry its own :data via the layer options map. This is how reference lines, prediction overlays, and small annotation datasets attach to a plot. The wrinkle is what the layer’s columns must refer to: a panel has one x-axis and one y-axis, both identified by their column ref, so a layer that renders on a panel uses the panel’s column refs to look up values in its data.

That rule gives two patterns – “overlay on the same panel” and “this layer on a separate sub-pose” – with different mechanics. Knowing which one you want determines the call shape.

Overlay on the same panel

There are two ways to draw the layer on the existing panel with data from elsewhere. The first is to use the panel’s column refs, renaming the incoming dataset’s columns to match via tc/rename-columns; a layer whose columns match the panel’s joins it. Here a base scatter is overlaid with a second set of points from another dataset:

(def overlay-base
  {:fitted   [1 2 3]
   :residual [1 2 3]})
(def overlay-other
  (tc/dataset {:x [0.5 1.5 2.5]
               :y [1.5 2.5 3.5]}))
(-> overlay-base
    (pj/lay-point :fitted :residual {:color "#377eb8"})
    (pj/lay-point :fitted :residual
                  {:color "#e6550d"
                   :data (tc/rename-columns overlay-other
                                            {:x :fitted :y :residual})}))
residualfitted0.51.01.52.02.53.01.01.52.02.53.03.5

Both layers use the pose’s :fitted and :residual column refs; the second layer’s data, renamed to those columns, renders into the same panel. The result is one panel with six points – three from each layer. Overlaid layers paint in the order they were added – see Poses – so add the layer you want on top last.

The second way is to say outright that you meant an overlay, and leave the incoming columns alone. pj/overlay marks the pose, and every layer added after it joins the panel:

(-> overlay-base
    (pj/lay-point :fitted :residual {:color "#377eb8"})
    pj/overlay
    (pj/lay-point :x :y {:color "#e6550d" :data overlay-other}))
residualfitted0.51.01.52.02.53.01.01.52.02.53.03.5

Same picture, no renaming. Which to reach for depends on what the incoming columns mean: rename when they are the same quantity under another name, and use pj/overlay when they are a different quantity you want read against the same axes.

Separate sub-pose for the new layer

To put the new layer on its own panel, name the layer’s columns directly. When the new layer’s position does not match the existing one, the pose splits into two panels: the original leaf becomes panel-1, and a new sub-pose carrying the new position and the new layer becomes panel-2. The default :matrix layout places a panel by its columns – :x picks the column of the grid, :y picks the row – so two panels naming four different columns sit on the diagonal of a two-by-two grid and the other two cells stay empty. This promotion is specified as Rule LP2 in the Pose Rules chapter.

(-> overlay-base
    (pj/lay-point :fitted :residual {:color "#377eb8"})
    (pj/lay-point :x :y {:color "#e6550d" :data overlay-other}))
1231231223fittedxresidualy

Each panel has three points and its own x/y axis labels: panel-1 shows fitted vs residual, panel-2 shows x vs y. To put the two panels in a row instead of on a diagonal – or to set weights, share scales, or give an explicit grid – build the composite via pj/arrange or the explicit composite-pose form shown earlier in the chapter.

Panels Clip Their Own Content

Each panel confines its marks to its own rectangle. A layer whose data runs past the panel’s domain – a reference line drawn well beyond the visible range, say – is clipped at the panel edge rather than painting across its neighbours. The clip is applied per panel, so a stacked arrangement reports one clip region per panel.

(def bounded
  (-> {:x [1 2 3 4 5] :y [10 20 15 25 18]}
      (pj/lay-point :x :y)
      (pj/lay-line {:data {:x [1 5] :y [-200 300]}})
      (pj/scale :y {:type :linear :domain [0 30]})))
(pj/arrange [bounded bounded] {:cols 1})
yx123450102030yx123450102030

Notes on the Current Implementation

A few details about how composition renders today, in case they matter for a layout you’re sketching:

  • Each leaf draws its own axes, labels, and ticks. Shared scales align the data ranges across panels, but these decorations are per-leaf in :horizontal and :vertical layouts, so a stack of cells reading one x column carries that axis label once per cell. A cell drops its own with :suppress-x-ticks and :suppress-x-label in that cell’s :opts, which is what pj/marginal writes on the strip it builds. (Matrix layouts – the SPLOM grid in particular – replace per-leaf x/y labels with shared strip labels, and SPLOM additionally suppresses ticks on interior cells.)

  • Plot-area edges may not line up across composite siblings, since each leaf reserves its own padding for axes and labels – two sub-poses with different label lengths, or one drawing a legend that its neighbour does not, produce visibly different panel widths. pj/marginal is the one construction that answers this today: it plans its two cells against a common floor for both the y-label pad and the legend column.

  • Each cell has its own plot options. A cell is a pose, so pj/options, pj/scale and pj/coord written on it apply to that cell: one cell can be log-scaled and its neighbour linear, and each can have its own title. Written on the composite itself, they apply to every cell that does not set them. This is also the way around a limitation within one pose – two layers cannot read one aesthetic through different scales, but two cells can.

  • Legends merge when sibling sub-poses agree on an aesthetic. If every leaf maps the same aesthetic identically (e.g., :color :species in all cells), the compositor renders a single shared legend at composite level. When the mappings disagree – or when only some leaves carry the aesthetic, as in the dashboard above – each leaf with that aesthetic renders its own legend.

  • Multi-row layouts go through pj/arrange. Both pj/arrange and the explicit-map form accept only leaf cells; a row of rows or column of rows is built by passing nested vectors of leaves to pj/arrange (the dashboard example above shows the shape). Nested composites (a sub-pose that is itself composite) are out of scope today.

The legend note is the one to see rather than take on trust. Both cells below map :color to the same column, so the two legends collapse into a single one drawn for the composite as a whole:

(pj/arrange
 [(-> (rdatasets/datasets-iris)
      (pj/lay-point :sepal-length :sepal-width {:color :species}))
  (-> (rdatasets/datasets-iris)
      (pj/lay-point :petal-length :petal-width {:color :species}))])
sepal widthsepal length682.02.53.03.54.04.5petal widthpetal length50.00.51.01.52.02.5speciessetosaversicolorvirginica

What’s Next

  • Options and Scopes – the taxonomy of layer options, plot options, and configuration (next chapter in Foundations)

  • Faceting – panel splits by a categorical column (a data-driven composite, covered in How-to)

  • Gallery – more composition examples alongside single-plot chart types

source: notebooks/plotje_book/composition.clj