8  Pose Rules

Pose Model gave the mental picture; this chapter proves it. Each of the 29 rules below carries a rendered pose (or plan), a printed structure, and a tested assertion, so the model claims are verified on every run.

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/options, pj/scale, pj/coord, pj/facet, and pj/cross.

Read Pose Model first – this chapter is the proof layer, not a teaching chapter.

(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
: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; sub-poses can be leaves or further composites. A layer is a map with :layer-type and an optional :mapping, plus sibling keys :stat, :position, :mark when the user provides them.

The rules below assume some familiarity with these shapes. If this is new, Pose Model shows them in use before we formalize them here.


Construction

How poses and composites come into existence. Eight 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 decides 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))
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5
(-> 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))
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

And an aesthetic-on-aesthetic extension merges with later-wins:

(-> iris
    (pj/pose {:color :species})
    (pj/pose :sepal-length :sepal-width))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

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))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Rule 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))
5678234246012sepal lengthpetal lengthsepal widthpetal 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)
    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))
5678234246012sepal lengthpetal lengthsepal widthpetal widthIris

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}))
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5speciessetosaversicolorvirginica

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))
682345012sepal lengthpetal lengthsepal widthpetal widthspeciessetosaversicolorvirginica

Rule 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))
5678234246012sepal lengthpetal lengthsepal widthpetal 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))
5678234246012sepal lengthpetal lengthsepal widthpetal 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 – no-op (see C7).

(-> iris
    (pj/pose :sepal-length :sepal-width)
    (pj/pose :petal-length :petal-width)
    (pj/pose :sepal-length :petal-length))
567823424601256785sepal lengthpetal lengthsepal widthpetal widthpetal 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})
    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 is a no-op

(pj/pose pose) where pose is a leaf or a composite returns pose unchanged. This makes the 1-arity pj/pose safe as a syntactic nullity.

(let [pose (-> iris (pj/pose :sepal-length :sepal-width))]
  (= pose (pj/pose pose)))
true
(let [pose (-> iris
               (pj/pose :sepal-length :sepal-width)
               (pj/pose :petal-length :petal-width))]
  (= pose (pj/pose pose)))
true

Rule 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)])
sepal widthsepal length56782.02.53.03.54.04.5petal widthpetal length2460.00.51.01.52.02.5

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}})
sepal widthsepal length56782.02.53.03.54.0petal widthpetal length2460.51.01.52.02.5Arranged

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 opts 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}))
246234246234petal-lengthpetal-widthsepal-lengthsepal-widthspeciessetosaversicolorvirginica

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))
true

Layer Placement

How lay-* calls decide where the layer lands in the pose tree. Four rules covering the bare-vs-position and leaf-vs-composite combinations, plus the raw-data convenience case.

Position storage (ratified 2026-04-23): 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 at plan time.

(-> iris
    (pj/pose :sepal-length :sepal-width)
    pj/lay-point)
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

On a composite, the same call attaches at root and reaches every panel at plan time:

(-> (pj/arrange
     [(pj/pose iris :sepal-length :sepal-width)
      (pj/pose iris :petal-length :petal-width)])
    pj/lay-point
    pose-summary)
{:opts {:width 600, :height 400},
 :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; at plan time 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. Matching is keyword/string tolerant. The layer’s own :mapping carries the call’s position.

(-> iris
    (pj/pose :sepal-length :sepal-width)
    (pj/pose :petal-length :petal-width)
    (pj/lay-point :sepal-length :sepal-width))
5678234246012sepal lengthpetal lengthsepal widthpetal width

Keyword/string tolerance – the string form matches a keyword leaf (LP2 with LI2 keyword/string equivalence):

(-> iris
    (pj/pose :sepal-length :sepal-width)
    (pj/lay-point "sepal-length" "sepal-width"))
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Note on leaf-input with non-matching position (rejected). A panel has a single x-axis and a single y-axis. When the receiver is a single leaf that carries position and the lay-* call carries a different position, the call throws – distinct positional aesthetics mean distinct poses, and a layer can’t override the pose’s position to a different column. To draw with different x/y columns, build a multi-pair pose (pj/pose data [[:a :b] [:c :d]]) for separate panels, or arrange two leaves with pj/arrange.

(try
  (-> iris
      (pj/pose :sepal-length :sepal-width)
      (pj/lay-point :petal-length :petal-width))
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"lay-point was given position columns that conflict with the pose's existing position. A panel has a single x-axis and a single y-axis, so a layer can't override the pose's position to a different column. Conflicts: [{:x {:layer :petal-length, :pose :sepal-length}} {:y {:layer :petal-width, :pose :sepal-width}}]. To draw with different x/y columns, build a separate sub-pose: e.g.\n  (pj/arrange [base-pose (-> data (pj/lay-point :petal-length :petal-width))])\nor thread a multi-pair pose: (pj/pose data [[:a :b] [:c :d]])."

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. (Leaf-input with non-matching position is a separate case – overlay, per LP2 above.)

(-> iris
    (pj/pose :sepal-length :sepal-width)
    (pj/pose :petal-length :petal-width)
    (pj/lay-point :sepal-length :petal-length))
567823424601256785sepal lengthpetal lengthsepal widthpetal widthpetal length

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))
ba1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

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}]}

Leaf Identity

How columns identify a leaf. Two rules – one about inference when the user omits column names, one about how column refs are compared.

Rule LI1: few-column datasets auto-infer columns by position

When lay-* or pj/pose is called on a dataset without explicit column arguments, columns are inferred:

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)
(-> {:height [1 2 3] :weight [4 5 6] :species ["a" "b" "a"]}
    pj/lay-point)
weightheightspeciesab1.01.21.41.61.82.02.22.42.62.83.04.04.24.44.64.85.05.25.45.65.86.0

Four or more columns without explicit arguments throws:

(try
  (-> {: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)"

Rule LI2: column references compare tolerantly between keywords and strings

When matching column refs – whether a lay-* call’s position against a leaf’s, or inside scope resolution – :x and "x" are treated as the same column. The stored form is preserved as the user typed it; tolerance is a comparison property only.

(-> iris
    (pj/pose :sepal-length :sepal-width)
    (pj/lay-point "sepal-length" "sepal-width"))
sepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Storage preserves the user’s input – if you type a string, the pose holds a string:

(-> iris
    (pj/pose "sepal-length" "sepal-width")
    pose-summary)
{:mapping {:x "sepal-length", :y "sepal-width"}, :layers []}

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
   {:data iris
    :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}]}]}))
s1-composite
sepal widthsepal length682.02.53.03.54.04.5petal widthpetal length50.00.51.01.52.02.5speciessetosaversicolorvirginica

Property P-S1 – sibling independence. A sub-pose’s own mapping does not leak into its siblings.

(def s1-siblings
  (pj/pose
   {:data iris
    :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}]}]}))
s1-siblings
sepal widthsepal length56782.02.53.03.54.04.5petal widthpetal lengthspeciessetosaversicolorvirginica50.00.51.01.52.02.5

Rule 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
   {:data iris
    :poses [{:mapping {:x :sepal-length :y :sepal-width}
             :layers [{:layer-type :point}]}
            {:mapping {:x :a :y :b}
             :data (tc/dataset {:a [1 2 3] :b [3 5 4]})
             :layers [{:layer-type :point}]}]}))
s2-tree
sepal widthsepal length56782.02.53.03.54.04.5ba1233.03.23.43.63.84.04.24.44.64.85.0

Rule 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}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Rule S4: layer :mapping is the narrowest scope

A mapping written in a layer’s own :mapping (aesthetic options passed to lay-*) scopes to that layer only. Other layers – even on the same leaf – do not see it. 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}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

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"}))
Irissepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

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"}))
TwoSubsepal widthsepal length4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Rule O2: pj/scale and pj/coord are plot-level options

pj/scale writes into :opts under one of :x-scale, :y-scale, :size-scale, :alpha-scale, :fill-scale, or :color-scale – one key per channel. Axis channels (:x, :y) accept :linear, :log, :categorical; visual channels (:size, :alpha, :fill, :color) accept :linear and :log. pj/coord writes :coord. They apply to every leaf in the tree uniformly. (Per-panel scale variation is an open design question; today all are plot-wide.)

(-> iris
    (pj/pose :sepal-length :sepal-width)
    pj/lay-point
    (pj/scale :x :log)
    (pj/coord :flip))
sepal lengthsepal width2.02.53.03.54.04.55

A visual channel routes to its own opts key:

(-> iris
    (pj/pose :sepal-length :sepal-width {:size :petal-length})
    pj/lay-point
    (pj/scale :size :log))
sepal widthsepal lengthpetal length1.02.03.05.04.55.05.56.06.57.07.58.02.02.53.03.54.04.5

Rule O3: pj/facet writes the faceting column to :opts

pj/facet and pj/facet-grid store facet columns in :opts as :facet-col (and :facet-row for a grid). 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))
sepal widthsepal length682.02.53.03.54.04.56868setosaversicolorvirginica

A 2D grid uses both keys:

(-> iris
    (pj/pose :sepal-length :sepal-width)
    pj/lay-point
    (pj/facet-grid :species :species))
sepal widthsepal length024no data024no data05024no datano data05no datano data05setosaversicolorvirginicasetosaversicolorvirginica

Rule O4: pj/lay-rule-* and pj/lay-band-* are layers (annotations)

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. Position rides as layer-type keys (:y-intercept, :x-intercept, :y-min/:y-max, :x-min/:x-max), not column refs.

(-> iris
    (pj/pose :sepal-length :sepal-width)
    (pj/lay-point {:color :species})
    (pj/lay-rule-h {:y-intercept 3.0}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

A pose-scope annotation 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}))
5678234246012sepal lengthpetal lengthsepal widthpetal width

Assembly

How the rules above combine to produce rendered layers. The pj/draft pipeline stage is the observable output of assembly; each entry corresponds to one rendered layer.

Rule A1: one rendered layer per applicable (leaf, layer) pair

For each leaf in the resolved tree, 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 only
5678234246012sepal lengthpetal lengthsepal widthpetal width

Rule A2: each rendered layer carries fully merged scope

A draft entry reflects the full scope merge: effective :data, effective :mapping (covering both aesthetics and position), and :layer-type (plus any :stat, :position, :mark promoted to siblings). No scope level is dropped; no key is unresolved.

(-> iris
    (pj/pose :sepal-length :sepal-width {:color :species})
    pj/lay-point
    pj/draft)

[

{

:color :species
:x :sepal-length
:y :sepal-width
:mark :point
:stat :identity
: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
:__panel-idx 0

}

]


Layout

How leaves become panels in the rendered plot. Four rules covering single panels, overlays, faceting, and composite grids with shared scales.

Rule L1: each leaf produces a panel block

Each leaf in the resolved tree produces one panel block in the rendered plot. Without faceting, the block contains one panel. With pj/facet or pj/facet-grid, the block contains one panel per facet value (or per (row, col) pair).

(-> iris
    (pj/pose :sepal-length :sepal-width)
    (pj/pose :petal-length :petal-width)
    pj/lay-point
    pj/plan)
{:width 600,
 :height 400,
 :sub-plots
 [{:path [0],
   :rect [85.0 20.0 257.5 190.0],
   :plan
   {:panels
    [{:coord :cartesian,
      :y-domain [1.88 4.5200000000000005],
      :x-scale {:type :linear},
      :x-domain [4.12 8.08],
      :x-ticks
      {:values [5.0 6.0 7.0 8.0],
       :labels ["5" "6" "7" "8"],
       :categorical? false},
      :col 0,
      :layers
      [{:mark :point,
        :style {:opacity 0.75, :radius 3.0},
        :size-scale nil,
        :alpha-scale nil,
        :groups
        [{:color [0.2 0.2 0.2 1.0],
          :xs #tech.v3.dataset.column<float64>[150]
:sepal-length
[5.100, 4.900, 4.700, 4.600, 5.000, 5.400, 4.600, 5.000, 4.400, 4.900, 5.400, 4.800, 4.800, 4.300, 5.800, 5.700, 5.400, 5.100, 5.700, 5.100...],
          :ys #tech.v3.dataset.column<float64>[150]
:sepal-width
[3.500, 3.000, 3.200, 3.100, 3.600, 3.900, 3.400, 3.400, 2.900, 3.100, 3.700, 3.400, 3.000, 3.000, 4.000, 4.400, 3.900, 3.500, 3.800, 3.800...],
          :row-indices #tech.v3.dataset.column<int64>[150]
:__row-idx
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19...]}],
        :y-domain [2.0 4.4],
        :x-domain [4.3 7.9]}],
      :y-scale {:type :linear},
      :y-ticks
      {:values [2.0 3.0 4.0],
       :labels ["2" "3" "4"],
       :categorical? false},
      :row 0}],
    :width 258,
    :height 190,
    :caption nil,
    :total-width 258.0,
    :legend-position :none,
    :layout-type :single,
    :layout
    {:subtitle-pad 0,
     :legend-w 0,
     :caption-pad 0,
     :y-label-pad 16.5,
     :legend-h 0.0,
     :title-pad 0,
     :strip-h 0,
     :x-label-pad 17,
     :strip-w 0.0},
    :grid {:rows 1, :cols 1},
    :legend nil,
    :panel-height 173.0,
    :title nil,
    :y-label nil,
    :alpha-legend nil,
    :x-label nil,
    :subtitle nil,
    :panel-width 241.5,
    :size-legend nil,
    :total-height 190.0,
    :tooltip nil,
    :margin 10}}
  {:path [1],
   :rect [342.5 210.0 257.5 190.0],
   :plan
   {:panels
    [{:coord :cartesian,
      :y-domain [-0.01999999999999999 2.62],
      :x-scale {:type :linear},
      :x-domain [0.705 7.195],
      :x-ticks
      {:values [2.0 4.0 6.0],
       :labels ["2" "4" "6"],
       :categorical? false},
      :col 0,
      :layers
      [{:mark :point,
        :style {:opacity 0.75, :radius 3.0},
        :size-scale nil,
        :alpha-scale nil,
        :groups
        [{:color [0.2 0.2 0.2 1.0],
          :xs #tech.v3.dataset.column<float64>[150]
:petal-length
[1.400, 1.400, 1.300, 1.500, 1.400, 1.700, 1.400, 1.500, 1.400, 1.500, 1.500, 1.600, 1.400, 1.100, 1.200, 1.500, 1.300, 1.400, 1.700, 1.500...],
          :ys #tech.v3.dataset.column<float64>[150]
:petal-width
[0.2000, 0.2000, 0.2000, 0.2000, 0.2000, 0.4000, 0.3000, 0.2000, 0.2000, 0.1000, 0.2000, 0.2000, 0.1000, 0.1000, 0.2000, 0.4000, 0.4000, 0.3000, 0.3000, 0.3000...],
          :row-indices #tech.v3.dataset.column<int64>[150]
:__row-idx
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19...]}],
        :y-domain [0.1 2.5],
        :x-domain [1.0 6.9]}],
      :y-scale {:type :linear},
      :y-ticks
      {:values [-0.0 1.0 2.0],
       :labels ["0" "1" "2"],
       :categorical? false},
      :row 0}],
    :width 258,
    :height 190,
    :caption nil,
    :total-width 258.0,
    :legend-position :none,
    :layout-type :single,
    :layout
    {:subtitle-pad 0,
     :legend-w 0,
     :caption-pad 0,
     :y-label-pad 16.5,
     :legend-h 0.0,
     :title-pad 0,
     :strip-h 0,
     :x-label-pad 17,
     :strip-w 0.0},
    :grid {:rows 1, :cols 1},
    :legend nil,
    :panel-height 173.0,
    :title nil,
    :y-label nil,
    :alpha-legend nil,
    :x-label nil,
    :subtitle nil,
    :panel-width 241.5,
    :size-legend nil,
    :total-height 190.0,
    :tooltip nil,
    :margin 10}}],
 :chrome
 {:legend-w 0,
  :row-labels ["sepal width" "petal width"],
  :layout {[0] [85.0 20.0 257.5 190.0], [1] [342.5 210.0 257.5 190.0]},
  :shared-aesthetics #{},
  :matrix? true,
  :title nil,
  :n-cols 2,
  :n-rows 2,
  :col-labels ["sepal length" "petal length"],
  :strip-h 20,
  :grid-rect [85.0 20.0 515.0 380.0],
  :strip-w 85,
  :shared-legend nil,
  :title-band-h 0},
 :composite? true,
 :total-width 600,
 :total-height 400,
 :title nil}

Rule L2: layers within one leaf overlay within that leaf’s panel block

All layers applicable to a leaf (the leaf’s own plus all ancestor root-origin layers) draw on the same axis pair – they overlay within each panel of that leaf’s block, not on separate panels.

(-> iris
    (pj/pose :sepal-length :sepal-width {:color :species})
    pj/lay-point
    (pj/lay-smooth {:stat :linear-model}))
sepal widthsepal lengthspeciessetosaversicolorvirginica4.55.05.56.06.57.07.58.02.02.53.03.54.04.5

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))
sepal widthsepal length682.02.53.03.54.04.56868setosaversicolorvirginica

Rule L4: composite layout is controlled by :layout and optional :share-scales

A composite pose carries a :layout map (set by pj/arrange options: :cols, :width, :height, :title) controlling the grid of its sub-poses’ panel blocks. An optional :share-scales (subset of #{:x :y}) enables column-bucketed shared-scale resolution across sub-poses.

Column-bucketing: when :x is shared, sub-poses whose effective :x column is the same share that scale’s domain; sub-poses with different :x columns get independent x-domains. Same for :y. This is what enables SPLOM (aligning columns down, rows across) and marginal plots (x shared between scatter and top density; right density has its own y).

(def l4-shared
  (pj/arrange
   [(-> iris (pj/pose :sepal-length :sepal-width) pj/lay-point)
    (-> iris (pj/pose :sepal-length :petal-width) pj/lay-point)]
   {:share-scales #{:x}}))
l4-shared
sepal widthsepal length5672.02.53.03.54.04.5petal widthsepal length5670.00.51.01.52.02.5

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. Each cell inherits the base’s :data, root :mapping, and root :layers at plan time. The compositor applies three renderer flags on cells:

  • :suppress-legend true on every cell (one shared legend is drawn at composite level).

  • :suppress-x-label true on every non-bottom row (x-axis label shows only on the bottom row).

  • :suppress-y-label true on every non-leftmost column (y-axis label shows only on the leftmost column).

Idiomatic 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}))
246234246234petal-lengthpetal-widthsepal-lengthsepal-widthspeciessetosaversicolorvirginica

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 the rules above orchestrate

source: notebooks/plotje_book/pose_rules.clj