9 Pose Rules
Each rule below states one behaviour and shows it on a rendered pose, with the printed structure where the shape is the point.
The rules are organized into seven sections (Construction, Layer Placement, Leaf Identity, Scope, Options, Assembly, Layout) and cover every call shape of pj/pose, pj/lay-*, pj/arrange, pj/overlay, pj/options, pj/scale, pj/coord, pj/facet, and pj/cross.
Read Poses first. On a first read through the book you can skim or skip this chapter, and return when you want the precise rule behind a behavior.
(ns plotje-book.pose-rules
(:require
;; Kindly -- notebook rendering protocol
[scicloj.kindly.v4.kind :as kind]
;; Tablecloth -- dataset operations
[tablecloth.api :as tc]
;; Rdatasets -- standard datasets
[scicloj.metamorph.ml.rdatasets :as rdatasets]
;; Plotje -- composable plotting
[scicloj.plotje.api :as pj]))Setup
(def iris (rdatasets/datasets-iris))A helper to inspect pose structure without :data – the dataset is heavy and not what we are checking. We strip :data from the pose and every nested sub-pose and layer.
(defn strip-data [pose]
(cond-> (dissoc pose :data)
(:layers pose) (update :layers (partial mapv #(dissoc % :data)))
(:poses pose) (update :poses (partial mapv strip-data))))(defn pose-summary
"Print pose structure without :data (for readability)."
[pose]
(kind/pprint (strip-data pose)))Overview
A pose is a plain Clojure map with a documented set of keys:
| Key | On | Purpose |
|---|---|---|
:data |
leaf or any ancestor | dataset (Tablecloth) |
:mapping |
pose or layer | column-to-aesthetic bindings |
:layers |
pose | per-scope layers |
:poses |
composite only | sub-poses |
:layout |
composite | direction + weights |
:overlay |
leaf or composite | whether a lay-* naming other columns joins the panel rather than taking one of its own, wherever in the pipeline it is written (Rule LP5) |
:opts |
root | plot-level options (incl. composite-level keys like :share-scales) |
A leaf pose has :data, :mapping, :layers; no :poses. A composite pose has :poses, and a sub-pose may itself be a composite: pj/arrange wraps its cells in a row per line of the grid, so the composite it returns holds rows and each row holds cells. A composite the caller supplies is accepted the same way: pj/arrange given one among its inputs nests it (Rule C8), pj/facet called on one divides every cell of it (Rule O3), and a nested map written out and handed to pj/pose is read as written. So a grid inside a grid can be written directly. A layer is a map with :layer-type and an optional :mapping, plus sibling keys :stat, :position, :mark when the user provides them.
Construction
How poses and composites come into existence. Nine rules covering every pj/pose call shape plus pj/arrange.
Rule C1: pj/pose on raw data creates a leaf
Called with a dataset as first argument, pj/pose returns a leaf pose. The arity determines what’s in :mapping: a keyword is :x; two keywords are :x and :y; an options map contributes aesthetic keys.
(-> iris
(pj/pose :sepal-length :sepal-width))(-> iris
(pj/pose :sepal-length :sepal-width)
pose-summary){:mapping {:x :sepal-length, :y :sepal-width}, :layers []}With only an aesthetic mapping, position is omitted – the pose is a leaf with no position yet. Inference at render time will handle picking an axis if a layer is added without position.
(-> iris
(pj/pose {:color :species})
pose-summary){:mapping {:color :species}, :layers []}Rule C2: pj/pose on an unpositioned leaf extends its mapping
A leaf is unpositioned if neither its own :mapping nor any of its layers’ mappings carries :x or :y. Calling pj/pose again on such a leaf merges the new mapping into the leaf’s own; the leaf remains a leaf. No composite is created.
(-> iris
pj/pose
(pj/pose :sepal-length :sepal-width))And an aesthetic-on-aesthetic extension merges with later-wins:
(-> iris
(pj/pose {:color :species})
(pj/pose :sepal-length :sepal-width))Property P-C2 – construction commutativity. A chained unpositioned extension yields a pose structurally equal to the same content expressed as one call.
(= (-> iris
pj/pose
(pj/pose {:color :species})
(pj/pose :sepal-length :sepal-width))
(pj/pose iris :sepal-length :sepal-width {:color :species}))trueRule C3: pj/pose with position on a positioned leaf promotes to a composite
When pj/pose is called with position (:x/:y) on a leaf that already has position, the leaf becomes sub-pose 1 of a new composite and the call becomes sub-pose 2. If the leaf carried aesthetic alongside position, the aesthetic moves to the new composite’s root :mapping and flows to every sub-pose; the position stays with sub-pose 1.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width))When the leaf carried aesthetic + position, promotion splits them – aesthetic goes to root (flows to both panels), position stays with sub-pose 1:
(-> iris
(pj/pose :sepal-length :sepal-width {:color :species})
(pj/pose :petal-length :petal-width))(-> iris
(pj/pose :sepal-length :sepal-width {:color :species})
(pj/pose :petal-length :petal-width)
pose-summary){:mapping {:color :species},
:layout {:direction :matrix},
:poses
[{:mapping {:x :sepal-length, :y :sepal-width}, :layers []}
{:mapping {:x :petal-length, :y :petal-width}, :layers []}]}Property P-C3 – plot-level options stay at root on promotion. A :title set via pj/options before promotion does not demote into sub-pose 1; it lives on the composite root’s :opts.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/options {:title "Iris"})
(pj/pose :petal-length :petal-width))The printed structure shows :opts {:title "Iris"} at root and no :opts on sub-pose 1:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/options {:title "Iris"})
(pj/pose :petal-length :petal-width)
pose-summary){:opts {:title "Iris"},
:layout {:direction :matrix},
:poses
[{:mapping {:x :sepal-length, :y :sepal-width}, :layers []}
{:mapping {:x :petal-length, :y :petal-width}, :layers []}]}Rule C4: aesthetic-only pj/pose on a positioned leaf promotes without adding a panel
An aesthetic-only call (no :x/:y) on a positioned leaf wraps the leaf as sub-pose 1 of a new composite and routes the aesthetic to the composite’s root :mapping. The composite ends up with exactly one sub-pose. The purpose is to position the aesthetic at plot scope ahead of any subsequent panel.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose {:color :species}))Property P-C4 – aesthetic-then-panel equivalence. Aesthetic-only promotion followed by a position call equals bundling the aesthetic on the initial leaf and then promoting (C3’s mapping-split path). Users can switch between the two forms without changing the result.
(= (-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose {:color :species})
(pj/pose :petal-length :petal-width))
(-> iris
(pj/pose :sepal-length :sepal-width {:color :species})
(pj/pose :petal-length :petal-width)))trueRule C5: layer partitioning at promotion splits layers by position presence
When a positioned leaf is promoted (via C3 or C4), each layer is partitioned by a single test: a layer whose own :mapping contains :x or :y is panel-origin and stays with sub-pose 1; otherwise it is root-origin and moves to the composite’s root :layers, flowing to every sub-pose at plan time. No whitelist; the layer’s own mapping is self-describing.
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/pose :petal-length :petal-width))The bare pj/lay-point call is root-origin: at render time it reaches both panels. Had we passed position – (pj/lay-point :sepal-length :sepal-width) – the layer would stay with sub-pose 1.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width))Rule C6: pj/pose on a composite dispatches by call shape
Already a composite? The dispatch is:
Position-carrying call – append a new sub-pose to
:poses.Aesthetic-only call – merge the aesthetic into the root
:mapping(no new sub-pose).Empty call – has no effect (see C7).
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/pose :sepal-length :petal-length))An aesthetic-only call on a composite merges into root, leaving sub-poses alone:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/pose {:color :species}))(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/pose {:color :species})
pose-summary){:mapping {:color :species},
:layout {:direction :matrix},
:poses
[{:mapping {:x :sepal-length, :y :sepal-width}, :layers []}
{:mapping {:x :petal-length, :y :petal-width}, :layers []}]}Rule C7: empty pj/pose on an existing pose has no effect
(pj/pose pose) where pose is a leaf or a composite returns pose unchanged. This makes the 1-arity pj/pose safe to use as a pipeline step that does nothing.
A leaf pose. First the rendered plot, then its structure:
(def leaf-pose (-> iris (pj/pose :sepal-length :sepal-width)))leaf-pose(pose-summary leaf-pose){:mapping {:x :sepal-length, :y :sepal-width}, :layers []}Wrapping it with 1-arity pj/pose returns the same value:
(= leaf-pose (pj/pose leaf-pose))trueA composite pose. The rendered plot, then its structure:
(def composite-pose
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)))composite-pose(pose-summary composite-pose){:layout {:direction :matrix},
:poses
[{:mapping {:x :sepal-length, :y :sepal-width}, :layers []}
{:mapping {:x :petal-length, :y :petal-width}, :layers []}]}And again, 1-arity pj/pose has no effect:
(= composite-pose (pj/pose composite-pose))trueRule C8: pj/arrange composes poses into a composite
pj/arrange takes a sequence of poses (leaves in alpha) plus optional layout options and returns a composite. The inputs become the composite’s :poses, wrapped in a 2-level row-and-column layout.
(pj/arrange
[(-> iris (pj/pose :sepal-length :sepal-width) pj/lay-point)
(-> iris (pj/pose :petal-length :petal-width) pj/lay-point)])A cell may itself be a composite, and nests rather than being flattened – which is the shape pj/arrange builds for itself, one row per line of the grid:
(-> (pj/arrange [(pj/arrange [(pj/pose iris :sepal-length :sepal-width)
(pj/pose iris :petal-length :petal-width)])
(pj/pose iris :sepal-length :petal-length)])
pj/lay-point)Opts (:cols, :title, :width, :height, :share-scales) route into the composite’s :opts / :layout:
(pj/arrange
[(pj/pose iris :sepal-length :sepal-width)
(pj/pose iris :petal-length :petal-width)]
{:title "Arranged"
:share-scales #{:y}})Rule C9: 3-arity pj/pose with a pair sequence and opts folds aesthetic-attach into multi-pair
When pj/pose receives three arguments where the second is a pair sequence (a vector of [x y] pairs, typically the output of pj/cross) and the third is an options map, the call is equivalent to two chained calls: the opts mapping is attached to the base first (Rule C2 or C4), then the pair sequence is processed on top (Rule L5 for rectangular grids, otherwise a flat one-panel-per-pair composite). The aesthetic lands on the composite root and flows to every cell.
(-> iris
(pj/pose (pj/cross [:sepal-length :sepal-width]
[:petal-length :petal-width])
{:color :species}))Property P-C9 – multi-pair fold. The 3-arity form is structurally equal to the two-call form.
(let [a (-> iris
(pj/pose {:color :species})
(pj/pose (pj/cross [:sepal-length :sepal-width]
[:petal-length :petal-width])))
b (-> iris
(pj/pose (pj/cross [:sepal-length :sepal-width]
[:petal-length :petal-width])
{:color :species}))]
(= a b))trueLayer Placement
Where lay-* calls place the layer in the pose tree. Five rules covering whether the call names :x and :y, whether the receiver is a leaf or a composite, the raw-data convenience case, and the :overlay key that reverses the non-matching outcome.
Position storage: when a lay-* call carries position, the position lives on the layer’s own :mapping. The leaf being attached to (or created for) also carries position in its own :mapping where appropriate – both resolve to the same effective :x/:y via scope merge. The layer’s own :mapping is the authoritative record of what the user typed and is what C5 inspects at promotion.
Rule LP1: bare lay-* attaches at the current pose’s root
A lay-* call without position arguments attaches the layer to the current pose’s top-level :layers. On a leaf, that is the leaf’s own :layers. On a composite, it is the root :layers, and the layer flows into every descendant leaf when rendered.
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point)On a composite, the same call attaches at root and reaches every panel when rendered:
(-> (pj/arrange
[(pj/pose iris :sepal-length :sepal-width)
(pj/pose iris :petal-length :petal-width)])
pj/lay-point)(-> (pj/arrange
[(pj/pose iris :sepal-length :sepal-width)
(pj/pose iris :petal-length :petal-width)])
pj/lay-point
pose-summary){:opts {},
:layout {:direction :vertical},
:poses
[{:layout {:direction :horizontal},
:poses
[{:mapping {:x :sepal-length, :y :sepal-width}, :layers []}
{:mapping {:x :petal-length, :y :petal-width}, :layers []}]}],
:layers [{:layer-type :point}]}Property P-LP1 – bare layers flow downward. After adding one bare layer to a composite, the composite’s root :layers holds that single entry; each leaf inherits it (prepended), so every sub-plot renders the layer on top of its inferred or explicit leaf layers.
(let [before (pj/arrange
[(pj/pose iris :sepal-length :sepal-width)
(pj/pose iris :petal-length :petal-width)])
after (-> (pj/arrange
[(pj/pose iris :sepal-length :sepal-width)
(pj/pose iris :petal-length :petal-width)])
pj/lay-point)]
[(count (or (:layers before) []))
(count (or (:layers after) []))])[0 1]Rule LP2: position-carrying lay-* attaches to the DFS-last matching leaf
When lay-* carries :x/:y and at least one leaf has matching effective :x/:y (after ancestor merge), the layer attaches to the last such leaf in left-to-right depth-first order. The layer’s own :mapping carries the call’s position. This is the same “most recent matching leaf” rule the teaching chapters describe, formalized for composite poses where reading-order depth-first traversal gives the precise answer. Matching is by strict equality: a column reference is the keyword or string the user typed, and the two forms are not interchangeable.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/lay-point :sepal-length :sepal-width))Note on leaf-input with non-matching position. A leaf that already carries position, called with a lay-* that carries a different position, keeps the layer with the place the call named written on it. The pose stays a leaf; it draws one panel per place at draft time. lay-* does not restructure the pose, because which panels a leaf draws is settled where :overlay is read – see Rule LP5.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :petal-length :petal-width))Where the columns are written does not change the rule. A layer names its columns either in the call’s argument slots or in its options map, and identity reads both alike: (pj/lay-line pose :sepal-length :petal-width) and (pj/lay-line pose {:x :sepal-length :y :petal-width}) land on the same leaf. A written value is not a column – it places a mark on the panel the layer is added to and adds no panel of its own – and the layer’s data decides which of the two a value is.
Two lines over one :x, each naming its own :y in an options map, therefore draw two panels:
(-> {:X [1 2 3 4 5] :Y [1 2 3 4 5] :Z [1 4 9 16 25]}
(pj/pose :X)
(pj/lay-line {:y :Y})
(pj/lay-line {:y :Z}))A written value in the same slot joins instead. Here :x and :y are numbers on a dataset that has no column named 2, so the label is drawn on the panel the points are:
(-> {:X [1 2 3 4 5] :Y [1 2 3 4 5] :Z [1 4 9 16 25]}
(pj/lay-point :X :Y)
(pj/lay-text {:x 2 :y 4 :text "a note"}))pj/overlay is how a layer that does name other columns stays on the panel – see Rule LP5.
Rule LP3: on a composite, position-carrying lay-* misses append a new leaf at root
When lay-* carries :x/:y and no descendant leaf has matching effective :x/:y, a new leaf is appended at the composite’s root :poses. Its :mapping carries the call’s position; a single layer with matching position attaches to it. A leaf given non-matching columns stays a leaf instead, and draws a panel for them – see LP2 above.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/lay-point :sepal-length :petal-length))Property: LP2 and LP3 draw the same panels. Laying two non-matching positions on one pose (LP2) and building the composite explicitly via two pj/pose calls then attaching layers (LP3) draw the same panels, in the same order, with the same marks. The pose values differ: lay-* keeps one leaf and leaves the panels to be worked out at draft time, while pj/pose builds a composite.
(let [via-lay (-> iris
(pj/lay-point :sepal-length :sepal-width)
(pj/lay-point :petal-length :petal-width))
via-pose (-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/lay-point :sepal-length :sepal-width)
(pj/lay-point :petal-length :petal-width))
drawn (fn [pose] (select-keys (pj/svg-summary pose) [:panels :points]))]
{:via-lay-drawn (drawn via-lay)
:via-pose-drawn (drawn via-pose)
:via-lay-shape (if (:poses via-lay) :composite :leaf)
:via-pose-shape (if (:poses via-pose) :composite :leaf)})Note: a layer names :petal-length where this pose draws :sepal-length on :x, and :petal-width where it draws :sepal-width on :y, so the layer was given a panel of its own. To draw both on one panel: pj/overlay for the axis the pose already names, or {:x [:sepal-length :petal-length], :y [:sepal-width :petal-width]} in one lay-* call, or in the pose's mapping, to read them as series in pairs.
{:via-lay-drawn {:panels 2, :points 300},
:via-pose-drawn {:panels 2, :points 300},
:via-lay-shape :leaf,
:via-pose-shape :composite}Property: column-existence safety check on the new panel. When LP2 or LP3 would give a layer naming other columns a panel of its own, lay-* first checks that those columns exist in the data the layer would draw from – the layer’s own :data if present, otherwise the inherited data. A missing column is almost always a typo or a column name mismatch; the check raises a focused error at the call site rather than deferring to a generic “column not found” at plan time.
(try
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :nope :nada))
(catch clojure.lang.ExceptionInfo e
(ex-message e)))"lay-point :x :nope names a column that doesn't exist in the data the layer would draw from. Available columns: [:petal-length :petal-width :rownames :sepal-length :sepal-width :species]. The :x differs from the column the pose draws, so this layer would take a panel of its own -- but its data has no column :nope. If you meant to draw this layer on the panel the pose already draws, write pj/overlay on the pose, or {:overlay true} in its own options map. If you meant another dataset, pass `:data` on this lay-* call."Supplying :data on the lay-* call satisfies the safety check – the new panel has its own data with the new columns.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :foo :bar
{:data (tc/dataset {:foo [1 2 3] :bar [4 5 6]})}))Rule LP4: lay-* on raw data coerces the data into a leaf pose
lay-* called with a dataset as its first argument coerces the data into a leaf pose first, then applies the layer. The result is a leaf pose equivalent to (-> data (pj/pose :x :y) pj/lay-point). This keeps the convenience one-liner (-> data (pj/lay-point :x :y)) working as the shortest path from data to plot.
(def tiny
{:a [1 2 3 4 5]
:b [2 4 3 5 4]})(-> tiny
(pj/lay-point :a :b))The explicit two-step form produces the same leaf pose:
(-> tiny
(pj/pose :a :b)
pj/lay-point
pose-summary){:mapping {:x :a, :y :b}, :layers [{:layer-type :point}]}Rule LP5: :overlay keeps a non-matching lay-* on the panel
Under LP2 a lay-* whose effective :x/:y match no panel the leaf draws takes a panel of its own, and under LP3 a composite appends a leaf for it. With :overlay it joins the panel it is added to instead. Its :x and :y stay on the layer’s own :mapping, and the leaf’s :mapping is left as it was.
Where nothing written distinguishes the layers, each overlaid layer takes a colour and a legend entry naming its column, and the axis names every column drawn on it. Each layer below is given a written colour, so the overlay adds nothing, and the axes keep the titles of the leaf’s own columns.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :sepal-length :sepal-width {:color "#377eb8"})
pj/overlay
(pj/lay-point :petal-length :petal-width {:color "#e6550d"}))One panel with the blue sepal points and the orange petal points on it, the axes titled for the leaf’s own columns, and their domains stretched to hold the petal values too. Printed, the leaf’s :mapping is untouched and the petal columns sit on the second layer:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :sepal-length :sepal-width {:color "#377eb8"})
pj/overlay
(pj/lay-point :petal-length :petal-width {:color "#e6550d"})
pose-summary){:mapping {:x :sepal-length, :y :sepal-width},
:layers
[{:layer-type :point,
:mapping {:x :sepal-length, :y :sepal-width, :color "#377eb8"}}
{:layer-type :point,
:mapping {:x :petal-length, :y :petal-width, :color "#e6550d"}}],
:overlay true}On a composite, where LP3 would append a new leaf, the layer joins the last leaf in left-to-right depth-first order – the panel being built. LP2 picks the last leaf that matches; with none matching, :overlay picks the last leaf there is.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/lay-point :sepal-length :sepal-width {:color "#377eb8"})
(pj/lay-point :petal-length :petal-width {:color "#e6550d"})
pj/overlay
(pj/lay-point :sepal-width :petal-width {:color "#4daf4a"}))The green marks are drawn on the orange panel, not on a third panel of their own. Printed, the third layer is on the second sub-pose:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/lay-point :sepal-length :sepal-width {:color "#377eb8"})
(pj/lay-point :petal-length :petal-width {:color "#e6550d"})
pj/overlay
(pj/lay-point :sepal-width :petal-width {:color "#4daf4a"})
pose-summary){:layout {:direction :matrix},
:poses
[{:mapping {:x :sepal-length, :y :sepal-width},
:layers [{:layer-type :point, :mapping {:color "#377eb8"}}]}
{:mapping {:x :petal-length, :y :petal-width},
:layers
[{:layer-type :point, :mapping {:color "#e6550d"}}
{:layer-type :point,
:mapping {:color "#4daf4a", :x :sepal-width, :y :petal-width},
:overlay true}]}],
:overlay true}{:overlay false} in one call’s options map opts that call out of a pj/overlay set further out, so the orange layer goes back to starting a panel of its own:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :sepal-length :sepal-width {:color "#377eb8"})
pj/overlay
(pj/lay-point :petal-length :petal-width {:color "#e6550d" :overlay false}))(pj/overlay pose false) draws the same two panels:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :sepal-length :sepal-width {:color "#377eb8"})
pj/overlay
(pj/overlay false)
(pj/lay-point :petal-length :petal-width {:color "#e6550d"}))Unlike the layer-level form, (pj/overlay pose false) removes the key from the pose, so no layer of the pose reads the key. The key says where a layer goes rather than what it draws, and it is read where the panels are decided rather than where a layer is written – so a layer’s own :overlay is kept on the layer for that reading, while the pose here carries none:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point :sepal-length :sepal-width {:color "#377eb8"})
pj/overlay
(pj/overlay false)
(pj/lay-point :petal-length :petal-width {:color "#e6550d"})
pose-summary){:mapping {:x :sepal-length, :y :sepal-width},
:layers
[{:layer-type :point,
:mapping {:x :sepal-length, :y :sepal-width, :color "#377eb8"}}
{:layer-type :point,
:mapping {:x :petal-length, :y :petal-width, :color "#e6550d"}}]}Because the key is read there and not where a layer is added, pj/overlay says the same thing at either end of a pipeline:
[(-> iris
pj/overlay
(pj/lay-point :sepal-length :sepal-width)
(pj/lay-point :petal-length :petal-width)
pj/svg-summary
:panels)
(-> iris
(pj/lay-point :sepal-length :sepal-width)
(pj/lay-point :petal-length :petal-width)
pj/overlay
pj/svg-summary
:panels)][1 1]A layer whose :x and :y already match the leaf is unaffected: it was joining under LP2 anyway, so :overlay changes nothing there.
Leaf Identity
How columns identify a leaf. One rule, about inference when the user omits column names. (How column references are compared is covered by Rule LP2.)
Rule LI1: few-column datasets infer columns from column order
When lay-* or pj/pose is called on a dataset without explicit column arguments, the mapping is read off the order of the dataset’s columns:
| Columns | Inferred mapping |
|---|---|
| 1 | {:x col0} |
| 2 | {:x col0 :y col1} |
| 3 | {:x col0 :y col1 :color col2} |
| 4+ | error (pass explicit x and y) |
The example is a dataset rather than a map literal, because the rule reads an order and a Clojure map does not carry one.
(-> (tc/dataset {:height [1 2 3] :weight [4 5 6] :species ["a" "b" "a"]})
pj/lay-point)Four or more columns without explicit arguments throws:
(try
(-> (tc/dataset {:a [1 2] :b [3 4] :c [5 6] :d [7 8]})
pj/lay-point)
(catch Exception e
(ex-message e)))"Cannot auto-infer columns from 4 columns. Pass explicit x and y: (pj/lay-point data :x :y). Available columns: (:a :b :c :d)"Scope
How mappings and data flow through the pose tree. Four rules covering the root, then composite, then leaf, then layer chain at arbitrary depth.
Rule S1: mapping scope is a tree-walk merge; narrower wins
The effective :mapping for a rendered layer is computed by merging, in order: root’s :mapping, then each ancestor composite’s :mapping, then the leaf’s own :mapping, then the layer’s own :mapping. Inner keys override outer. Any depth of composite nesting works the same way.
Root-level aesthetic flows to every leaf. Using a two-panel composite with :color declared at root:
(def s1-composite
(pj/pose
{:mapping {:color :species}
:poses [{:mapping {:x :sepal-length :y :sepal-width}
:layers [{:layer-type :point}]}
{:mapping {:x :petal-length :y :petal-width}
:layers [{:layer-type :point}]}]
:data iris}))s1-compositeProperty P-S1 – sibling independence. A sub-pose’s own mapping does not leak into its siblings.
(def s1-siblings
(pj/pose
{:poses [{:mapping {:x :sepal-length :y :sepal-width}
:layers [{:layer-type :point}]}
{:mapping {:x :petal-length :y :petal-width :color :species}
:layers [{:layer-type :point}]}]
:data iris}))s1-siblingsRule S2: data scope – nearest-ancestor-non-nil wins
The effective :data for a rendered layer is the nearest non-nil :data walking from the layer up through each ancestor to the root. Layer :data > leaf :data > nearest ancestor composite :data > root :data. Unlike mappings, data does not merge – it is picked, wholesale.
(def s2-tree
(pj/pose
{:poses [{:mapping {:x :sepal-length :y :sepal-width}
:layers [{:layer-type :point}]}
{:mapping {:x :a :y :b}
:layers [{:layer-type :point}]
:data (tc/dataset {:a [1 2 3] :b [3 5 4]})}]
:data iris}))s2-treeRule S3: nil in a mapping cancels an inherited value
Assigning nil to a mapping key at an inner scope cancels the value inherited from outer scopes. The rendering path treats a nil mapping value as equivalent to “no mapping for that aesthetic.”
(-> iris
(pj/pose :sepal-length :sepal-width {:color :species})
pj/lay-point
(pj/lay-smooth {:color nil :stat :linear-model}))Rule S4: layer :mapping is the narrowest scope
A mapping written in a layer’s own :mapping (aesthetic mappings passed to lay-*) scopes to that layer only. Other layers – even on the same leaf – do not read that mapping. This is the terminal case of S1: the layer’s mapping is innermost in the merge.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point {:color :species})
(pj/lay-smooth {:stat :linear-model}))Options
Plot-level options and modifiers. Unlike mappings, layers, and data (which live in the scope hierarchy), options configure the whole rendered plot and attach to the root’s :opts.
Rule O1: pj/options writes to the root’s :opts
pj/options merges its argument into the current pose’s :opts. On a leaf, that is the leaf’s :opts. On a composite, the root’s. Options do not flow down like mappings – they are plot-level, not layer-level.
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/options {:title "Iris"}))Repeated calls merge, later-wins on collisions:
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/options {:title "One"})
(pj/options {:title "Two" :subtitle "Sub"}))Rule O2: pj/scale writes the mapping, pj/coord writes options
A scale belongs to the aesthetic it reads, so pj/scale writes it into the pose’s :mapping, under that aesthetic’s :scale. It is the same key a mapping written out in full uses, so the two are one setting written two ways. pj/coord writes :coord into :opts, which has no aesthetic to belong to.
Axis aesthetics (:x, :y) accept :linear, :log, :categorical; the visual ones (:size, :alpha, :fill, :color) accept :linear and :log; :shape accepts :categorical. Both pj/scale and pj/coord flow down to every leaf beneath the pose they are called on.
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/scale :x :log)
(pj/coord :flip))Where the aesthetic is already mapped, the scale joins that mapping. A mapping written plainly has no room for one, so it is rewritten in the full form under :from – the plain reading spelled out, which leaves what the mapping names unchanged:
(-> iris
(pj/pose :sepal-length :sepal-width {:size :petal-length})
pj/lay-point
(pj/scale :size :log))Where it is not mapped at that pose, the scale is written on its own and applies to whatever names the aesthetic below:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point {:size :petal-length})
(pj/scale :size :log))Rule O3: pj/facet writes the faceting column to the mapping
pj/facet and pj/facet-grid write the faceting column into the pose’s :mapping under :col (and :row for a grid). These are aesthetics, so they follow the scope rules every other mapping follows: written on a pose they reach every layer and every sub-pose below, and a sub-pose that writes its own overrides them. The layout effect – splitting each leaf’s panel into a group of panels – happens at render time.
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/facet :species))A 2D grid uses both keys:
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/facet-grid :species :species))Because the faceting column is a mapping, a facet written on a composite reaches every cell of it, and a cell that writes its own overrides what it inherits:
(-> (pj/arrange [(pj/pose iris :sepal-length :sepal-width)
(pj/pose iris :petal-length :petal-width)])
pj/lay-point
(pj/facet :species))(-> (pj/arrange [(-> (pj/pose iris :sepal-length :sepal-width)
(pj/facet :species))
(pj/pose iris :petal-length :petal-width)])
pj/lay-point)Rule O4: pj/lay-rule-* and pj/lay-band-* are layers
pj/lay-rule-h, pj/lay-rule-v, pj/lay-band-h, pj/lay-band-v produce layers and scope like any other lay-*: bare call attaches at root (flows to every panel); 4-arity with column refs attaches to a matching leaf. Where the line or the band sits is written as layer-type keys (:y-intercept, :x-intercept, :y-min/:y-max, :x-min/:x-max), not as column references.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/lay-point {:color :species})
(pj/lay-rule-h {:y-intercept 3.0}))A pose-scope rule via the 4-arity attaches to a matching leaf, not every panel:
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
(pj/lay-rule-h :sepal-length :sepal-width {:y-intercept 3.0}))Assembly
How the rules above combine to produce rendered layers. Each rendered layer corresponds to one (leaf, applicable-layer) pair, with all ancestor scope merged in.
Rule A1: one rendered layer per applicable (leaf, layer) pair
For each leaf, the number of rendered layers equals the number of layers applicable to that leaf – the leaf’s own plus all ancestor root-origin layers.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
pj/lay-point ;; root-origin; reaches both
(pj/lay-smooth :sepal-length :sepal-width
{:stat :linear-model})) ;; panel-origin; sub-pose 1 onlyRule A2: each rendered layer carries fully merged scope
Every layer rendered in the final plot reflects the full scope merge: the dataset, every mapping (positions and aesthetics) that flows into it, and the layer-type bundle (plus any :stat, :position, :mark overrides). No scope level is dropped; no key is unresolved.
(-> iris
(pj/pose :sepal-length :sepal-width {:color :species})
pj/lay-point)Layout
How leaves become panels in the rendered plot. Five rules covering single panels, overlays, faceting, composite grids with shared scales, and the SPLOM reshape of multi-pair poses.
Rule L1: each leaf produces a panel block
Each leaf produces one panel block in the rendered plot. The block holds one panel per place its layers name, and pj/facet or pj/facet-grid multiplies those by the facet values (or by the (row, col) pairs). A leaf whose layers all name one place, unfaceted, is a single panel.
(-> iris
(pj/pose :sepal-length :sepal-width)
(pj/pose :petal-length :petal-width)
pj/lay-point)One leaf whose layers name two places is two panels in one block:
(-> iris
(pj/lay-point :sepal-length :sepal-width)
(pj/lay-point :petal-length :petal-width))Rule L2: layers naming one place overlay within that place’s panel
Layers applicable to a leaf (the leaf’s own plus all ancestor root-origin layers) that name the same place draw on the same axis pair – they overlay within each panel for that place, not on separate panels. Layers naming different places take a panel each, unless :overlay puts them together (Rule LP5).
(-> iris
(pj/pose :sepal-length :sepal-width {:color :species})
pj/lay-point
(pj/lay-smooth {:stat :linear-model}))Rule L3: faceting splits each leaf into panels by category
pj/facet :col produces one panel per unique value of :col; pj/facet-grid :row-col :col-col produces one panel per (row, col) pair.
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/facet :species))Rule L5: multi-pair pj/pose reshapes rectangular pairs into a 2D grid (SPLOM)
When pj/pose receives a pair-sequence that forms a rectangular M x N Cartesian product (like the output of pj/cross cols cols), the result is a nested rows-of-cols composite with :share-scales #{:x :y} – the canonical SPLOM layout. A cell’s place follows its columns: its :x decides which column of the grid it sits in, its :y which row. Each cell inherits the base’s :data, root :mapping, and root :layers when rendered. The compositor applies these renderer flags on cells:
:suppress-legend,:suppress-x-labeland:suppress-y-labelon every cell. One legend is drawn at composite level, and the strip labels carry the axis-variable names.:suppress-x-tickson every cell above the bottom row, and:suppress-y-tickson every cell right of the leftmost column. Every cell in a grid column carries the same:xand every cell in a row the same:y, so the bottom row’s numbers describe the whole of their column and the leftmost column’s the whole of their row.
In practice, SPLOM usage therefore omits pj/lay-point – each cell infers its own layer type: scatter off-diagonal, histogram on the diagonal (where x = y). Pair lists that are not rectangular fall through to the flat one-panel-per-pair behaviour (see Rules C3 / C6).
(-> iris
(pj/pose (pj/cross [:sepal-length :sepal-width]
[:petal-length :petal-width])
{:color :species}))A Note on pj/cross
pj/cross is not a rule. It is a pure pair-generator – (for [x xs y ys] [x y]) – returning [x-col y-col] pairs. It has no plot-level behavior on its own; the multi-pair arity of pj/pose (and pj/arrange for independent plots) is what turns the generated sequence into panels, and those cases are already covered by the rules above. pj/cross is shown as a SPLOM-construction ingredient in the chart-type and how-to chapters (Scatter, Faceting, Customization), not as a rule.
(pj/cross [:a :b] [:c :d])([:a :c] [:a :d] [:b :c] [:b :d])What’s Next
Inference Rules – how Plotje fills in defaults (column types, marks, stats, scales) when you do not specify them
Layer Types – the registry of mark + stat + position combinations