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))(-> 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))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))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))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))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))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 – no-op (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})
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)))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)])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 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}))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
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)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))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"))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))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}]}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)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"))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-compositeProperty 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-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
{: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-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 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}))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 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))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))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))A 2D grid uses both keys:
(-> iris
(pj/pose :sepal-length :sepal-width)
pj/lay-point
(pj/facet-grid :species :species))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}))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}))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 onlyRule 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
|
https://vincentarelbundock.github.io/Rdatasets/csv/datasets/iris.csv [150 6]:
|
:__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}))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. 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 trueon every cell (one shared legend is drawn at composite level).:suppress-x-label trueon every non-bottom row (x-axis label shows only on the bottom row).:suppress-y-label trueon 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}))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