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. 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}))])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})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
{: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:
weightedAnd 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.
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
{: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}]}]
:data (rdatasets/datasets-iris)}))marginalThe 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
(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}))]]))dashboardFour 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
To draw the layer on the existing panel with data from elsewhere, use the panel’s column refs. If your incoming dataset uses different names, rename them on the way in via tc/rename-columns. Here a base scatter is overlaid with a second set of points from another dataset:
(def overlay-base
(tc/dataset {: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)
(pj/lay-point :fitted :residual
{:data (tc/rename-columns overlay-other
{:x :fitted :y :residual})}))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.
Separate sub-pose for the new layer
To put the new layer on its own panel, name the layer’s columns directly. A non-matching position triggers LP2 promotion: the original leaf becomes panel-1; a new sub-pose carrying the new position and the new layer becomes panel-2. The two render side by side under the default :matrix layout.
(-> overlay-base
(pj/lay-point :fitted :residual)
(pj/lay-point :x :y {:data overlay-other}))Each panel has three points and its own x/y axis labels: panel-1 shows fitted vs residual, panel-2 shows x vs y. (For finer layout control – different weights, shared scales, or an explicit grid – build the composite via pj/arrange or the explicit composite-pose form shown earlier in the chapter.)
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
:horizontaland:verticallayouts, so the marginal example renders the x-axis label on both the strip and the scatter. (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 can produce visibly different panel widths.
Legends merge when sibling sub-poses agree on an aesthetic. If every leaf maps the same aesthetic identically (e.g.,
:color :speciesin 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. Bothpj/arrangeand the explicit-map form accept only leaf cells; a row of rows or column of rows is built by passing nested vectors of leaves topj/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