6  Composition

Plotjeโ€™s pose substrate lets 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 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.

(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 KPIs.

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
   {:data (rdatasets/datasets-iris)
    :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}]}]}))

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)
{: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 |
,
 :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}]}]}

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
   {:data (rdatasets/datasets-iris)
    :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}]}]}))
shared-x
sepal widthsepal length5672.02.53.03.54.04.5petal lengthsepal length5671234567

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 Marginal-Plot Pattern

The classic โ€œscatter with top densityโ€ โ€“ a distribution strip above the main plot โ€“ is a vertical composite with shared x:

(def marginal
  (pj/pose
   {:data (rdatasets/datasets-iris)
    :share-scales #{:x}
    :layout {:direction :vertical :weights [1 3]}
    :poses [{:mapping {:x :sepal-length}
             :layers [{:layer-type :density}]}
            {:mapping {:x :sepal-length :y :sepal-width :color :species}
             :layers [{:layer-type :point}]}]}))
marginal
sepal length4.55.05.56.06.57.07.50.00.20.4sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.52.02.53.03.54.04.5

The top panelโ€™s density curve aligns with the scatterโ€™s x-axis. Each panel retains its own y-axis because :share-scales here contains only :x.

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
  (let [iris (rdatasets/datasets-iris)]
    (pj/arrange
     [[(-> iris (pj/lay-histogram :sepal-length))
       (-> iris (pj/lay-boxplot :species :sepal-width {:color :species}))]
      [(-> iris (pj/lay-point :petal-length :petal-width {:color :species}))
       (-> iris (pj/lay-density :petal-length {:color :species}))]])))
dashboard
sepal length567801020sepal widthspeciesspeciessetosaversicolorvirginicano datasetosaversicolorvirginica234petal 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.

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 carries its own chrome โ€“ axes, labels, ticks. Shared scales align the data ranges across panels, but the visible chrome is per-leaf, so a marginal plot shows the x-axis label on both the main and the marginal panel.

  • Plot-area edges may not line up across composite siblings, since each leaf computes its own chrome padding โ€“ two sub-poses with different label lengths can produce visibly different panel widths.

  • Each sub-pose produces its own legend โ€“ if you map :color in two sibling sub-poses, the rendered plot shows two legends rather than a merged one.

  • 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.

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