9 Inference Rules
Plotje infers many parameters automatically so you can write less and get reasonable defaults. This notebook shows those rules in action by examining the plan β the resolved data structure that captures every inference decision.
This chapter is a reference: each rule in detail, with its default, its override, and a plan-level check. For the conceptual overview, read Pose Model and Core Concepts first. The examples here use small inline datasets so the full plan is readable.
(ns plotje-book.inference-rules
(:require
;; Tablecloth -- dataset manipulation
[tablecloth.api :as tc]
;; Kindly -- notebook rendering protocol
[scicloj.kindly.v4.kind :as kind]
;; Plotje -- composable plotting
[scicloj.plotje.api :as pj]
;; Rdatasets -- standard datasets
[scicloj.metamorph.ml.rdatasets :as rdatasets]))A Worked Example
Before the rule-by-rule tour, here is the kind of inference the chapter is going to dissect: a five-point scatter, then the plan that produced it. A plan is the fully resolved data structure Plotje builds from a pose right before rendering β a plain Clojure map with domains, ticks, scales, resolved layers, legend, and layout dimensions, every inference decision made explicit in one place. pj/plan is the function that produces it; we will use it to peek inside after each example.
(def five-points
{:x [1.0 2.0 3.0 4.0 5.0]
:y [2.1 4.3 3.0 5.2 4.8]})(def scatter-pose
(-> five-points
(pj/lay-point :x :y)))Here is the rendered plot:
scatter-poseAnd the full plan that produced it:
(pj/plan scatter-pose){:panels
[{:coord :cartesian,
:y-domain [1.945 5.355],
:x-scale {:type :linear},
:x-domain [0.8 5.2],
:x-ticks
{:values [1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["1.0" "1.5" "2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
: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>[5]
:x
[1.000, 2.000, 3.000, 4.000, 5.000],
:ys #tech.v3.dataset.column<float64>[5]
:y
[2.100, 4.300, 3.000, 5.200, 4.800],
:row-indices #tech.v3.dataset.column<int64>[5]
:__row-idx
[0, 1, 2, 3, 4]}],
:y-domain [2.1 5.2],
:x-domain [1.0 5.0]}],
:y-scale {:type :linear},
:y-ticks
{:values [2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 42.5,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "y",
:alpha-legend nil,
:x-label "x",
:subtitle nil,
:panel-width 557.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}Notice in the plan above:
:x-domainis[0.8 5.2]β wider than the data range[1.0, 5.0]because of 5% padding:x-scaleis{:type :linear}β inferred from numeric data:x-tickshas nice round values:1.0, 1.5, 2.0, ...:x-labelis"x"β derived from the column keyword:legendisnilβ no color mapping:layouthas:legend-w 0β no space reserved for a legendThe single layer has
:mark :pointand a single:groupsentry with all 5 data points, colored in the default color (dark gray,#333)
Each of those bullets is its own inference rule, with a default and an explicit override.
Overrides at a Glance
Every inference rule has an explicit override. The table below lists them all β scan it to find what you need, then jump to the matching section for the details and worked examples.
| What is inferred | Default | Override |
|---|---|---|
| Column selection | one column fills x; two fill x, y; three fill x, y, color | explicit column args in pj/pose or pj/lay-* |
| Column type | dtype inspection | :x-type, :y-type, :color-type in pose or layer options |
| Aesthetic classification | keyword = column, string = color/column | explicit :color keyword vs hex string |
| Grouping | categorical color column | :group aesthetic |
| Layer type (mark + stat) | column types (see Layer Type section) | pj/lay-point, pj/lay-histogram, etc. |
| Domain extent | data range + 5% padding | (pj/scale pose :x {:domain [0 10]}) |
| Domain zero-anchor | bar/stacked charts include zero | (pj/scale pose :y {:domain [5 20]}) |
| Fill domain | [0.0, 1.0] for fill position |
(pj/scale pose :y {:domain [0 2]}) |
| Tick values | round intervals (linear), powers of 10 (log) | wadogo scale configuration |
| Tick labels | number formatting, calendar formatting | wadogo label formatting |
| Axis labels | column name, with underscores replaced by spaces | (pj/options {:x-label "Custom"}) |
| Color legend | categorical = discrete, numerical = continuous, none = no legend | :color mapping controls presence |
| Size legend | 5 graduated circles when :size maps to numerical column |
:size mapping controls presence |
| Alpha legend | 5 graduated opacity squares when :alpha maps to numerical column |
:alpha mapping controls presence |
| Layout padding | adjusts for title, labels, legend | :width, :height in options |
| Layout type | single, facet-grid, multi-variable | pj/facet, multiple x-y pairs |
| Coordinate system | :cartesian |
(pj/coord :flip), (pj/coord :polar) |
The plan captures the result of all inference. When in doubt, inspect the plan.
The sections below walk each rule in detail. The order roughly follows the resolution pipeline β column selection, column types, aesthetics, grouping, layer type, domains, ticks, labels, legends, layout, coord flip β with two cross-cutting closing sections on how the rules combine in multi-layer plots and a diagram of the full resolution pipeline.
Column Selection
When column names are omitted, Plotje infers them from the dataset shape:
| Number of columns | Inferred mapping |
|---|---|
| 1 | first column becomes x |
| 2 | first becomes x, second becomes y |
| 3 | first becomes x, second becomes y, third becomes color |
| 4+ | no inference β see the note below |
The same rule applies to both entry points into the pipeline: pj/lay-* on raw data, and pj/pose on raw data. Both read the first 1-3 columns of the dataset in the order they appear and build the mapping from there.
One column:
(-> {:values [1 2 3 4 5 6]}
pj/lay-histogram)Two columns:
(-> {:x [1 2 3 4 5] :y [2 4 3 5 4]}
pj/lay-point)Three columns β the third becomes :color:
(-> {:x [1 2 3 4] :y [4 5 6 7] :g ["a" "a" "b" "b"]}
pj/lay-point)Pose construction infers the same mapping
Calling pj/pose on raw data without explicit column arguments runs the same column-selection rule. A 1-3 column dataset gets its mapping filled in; the resulting pose carries the mapping but has no layer attached yet, so layer type inference (covered below) chooses the mark when the pose renders.
(def two-col-pose
(pj/pose {:x [1.0 2.0 3.0 4.0 5.0]
:y [1.0 4.0 9.0 16.0 25.0]}))two-col-poseThe inferred mapping is visible on the pose itself:
(-> two-col-pose (select-keys [:mapping :layers]) kind/pprint){:mapping {:x :x, :y :y}, :layers []}4+ columns
With four or more columns there is no unambiguous default, so inference stops:
(pj/lay-* data)throws with a message listing the available columns, asking you to pass explicit:xand:y.(pj/pose data)is gentler β it builds a pose with the data attached but no mapping, so you can add one downstream with(pj/pose pose :col-a :col-b)or(pj/lay-point pose :col-a :col-b).
When you provide explicit columns, inference is skipped β you are in full control:
(-> (rdatasets/datasets-iris)
(pj/lay-point :petal-length :petal-width {:color :species}))Column Types
Once columns are selected, the next step is determining the type of each column β numerical, categorical, or temporal. This determines the scale type, domain, tick style, and the default mark.
| Column dtype | Inferred type |
|---|---|
| float, int | :numerical |
| string, keyword, boolean, symbol, text | :categorical |
| LocalDate, LocalDateTime, Instant, java.util.Date | :temporal (numerical, with calendar-aware ticks) |
Internally, infer-column-types in resolve.clj handles this step.
A categorical column produces a band scale with string domain values. Compare:
(def animals
{:animal ["cat" "dog" "bird" "fish"]
:count [12 8 15 5]})(def bar-pose
(-> animals
(pj/lay-value-bar :animal :count)))bar-poseAnd the plan:
(pj/plan bar-pose){:panels
[{:coord :cartesian,
:y-domain [-0.75 15.75],
:x-scale {:type :linear},
:x-domain ["cat" "dog" "bird" "fish"],
:x-ticks
{:values ["cat" "dog" "bird" "fish"],
:labels ["cat" "dog" "bird" "fish"],
:categorical? true},
:col 0,
:layers
[{:mark :rect,
:style {:opacity 0.85},
:position :dodge,
:groups
[{:color [0.2 0.2 0.2 1.0],
:label "",
:xs #tech.v3.dataset.column<string>[4]
:animal
[cat, dog, bird, fish],
:ys #tech.v3.dataset.column<int64>[4]
:count
[12, 8, 15, 5],
:dodge-idx 0}],
:y-domain [0 15],
:x-domain ("cat" "dog" "bird" "fish"),
:dodge-ctx {:n-groups 1}}],
:y-scale {:type :linear},
:y-ticks
{:values [0.0 2.0 4.0 6.0 8.0 10.0 12.0 14.0],
:labels ["0" "2" "4" "6" "8" "10" "12" "14"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 38.0,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "count",
:alpha-legend nil,
:x-label "animal",
:subtitle nil,
:panel-width 562.0,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}The x-domain is ["cat" "dog" "bird" "fish"] β strings in order of appearance. The ticks have :categorical? true. The y-domain starts at zero because this is a bar chart.
Temporal columns
Dates are detected and converted to epoch-milliseconds internally, with calendar-aware tick labels. Clojureβs #inst reader literal is a convenient way to write dates:
(def temporal-pose
(-> {:date [#inst "2024-01-01" #inst "2024-06-01" #inst "2024-12-01"]
:val [10 25 18]}
(pj/lay-point :date :val)))temporal-pose(let [p (first (:panels (pj/plan temporal-pose)))]
{:x-domain-numeric? (number? (first (:x-domain p)))
:tick-count (count (:values (:x-ticks p)))
:first-tick-label (first (:labels (:x-ticks p)))}){:x-domain-numeric? true, :tick-count 10, :first-tick-label "Feb-01"}The x-domain contains epoch-millisecond numbers, but the 10 tick labels show human-readable dates like "Feb-01". Plotje accepts java.util.Date (from #inst), LocalDate, LocalDateTime, and Instant β all are converted to epoch-milliseconds for plotting, with calendar-aware tick formatting.
Overriding inferred types with :x-type / :y-type
Sometimes a numeric column is really categorical β for example, hours of the day, years, or subject IDs. The inference system sees numbers and treats them as numerical, but you may want discrete categorical bands. Pass :x-type :categorical (or :y-type) to the pose or layer options to override:
(def hour-bar-pose
(-> {:hour [9 10 11 12] :count [5 8 12 7]}
(pj/lay-value-bar :hour :count {:x-type :categorical})))hour-bar-pose(:x-domain (first (:panels (pj/plan hour-bar-pose))))["9" "10" "11" "12"]Four bars at discrete hour bands. Without the override, lay-value-bar would reject the numeric :hour column; with it, the column is treated as categorical (values cast to strings for display). The same override exists for :y-type and for :color-type (see the Grouping section below for a :color-type example).
Aesthetic Resolution
The :color parameter triggers different behaviors depending on what you pass. Internally, resolve-aesthetics in resolve.clj classifies each aesthetic channel (:color, :size, :alpha, :text) as either a column reference or a fixed literal.
Column reference β colored by palette
(def colored-pose
(-> {:x [1 2 3 4 5 6]
:y [3 5 4 7 6 8]
:g ["a" "a" "a" "b" "b" "b"]}
(pj/lay-point :x :y {:color :g})))colored-poseAnd the plan:
(pj/plan colored-pose){:panels
[{:coord :cartesian,
:y-domain [2.75 8.25],
:x-scale {:type :linear},
:x-domain [0.75 6.25],
:x-ticks
{:values [1.0 2.0 3.0 4.0 5.0 6.0],
:labels ["1" "2" "3" "4" "5" "6"],
:categorical? false},
:col 0,
:layers
[{:mark :point,
:style {:opacity 0.75, :radius 3.0},
:size-scale nil,
:alpha-scale nil,
:groups
[{:color
[0.8941176470588236
0.10196078431372549
0.10980392156862745
1.0],
:xs #tech.v3.dataset.column<int64>[3]
:x
[1, 2, 3],
:ys #tech.v3.dataset.column<int64>[3]
:y
[3, 5, 4],
:label "a",
:row-indices #tech.v3.dataset.column<int64>[3]
:__row-idx
[0, 1, 2]}
{:color
[0.21568627450980393
0.49411764705882355
0.7215686274509804
1.0],
:xs #tech.v3.dataset.column<int64>[3]
:x
[4, 5, 6],
:ys #tech.v3.dataset.column<int64>[3]
:y
[7, 6, 8],
:label "b",
:row-indices #tech.v3.dataset.column<int64>[3]
:__row-idx
[3, 4, 5]}],
:y-domain [3 8],
:x-domain [1 6]}],
:y-scale {:type :linear},
:y-ticks
{:values [3.0 3.5 4.0 4.5 5.0 5.5 6.0 6.5 7.0 7.5 8.0],
:labels
["3.0"
"3.5"
"4.0"
"4.5"
"5.0"
"5.5"
"6.0"
"6.5"
"7.0"
"7.5"
"8.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :right,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 100,
:caption-pad 0,
:y-label-pad 42.5,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend
{:title :g,
:entries
[{:label "a",
:color
[0.8941176470588236 0.10196078431372549 0.10980392156862745 1.0]}
{:label "b",
:color
[0.21568627450980393
0.49411764705882355
0.7215686274509804
1.0]}]},
:panel-height 362.0,
:title nil,
:y-label "y",
:alpha-legend nil,
:x-label "x",
:subtitle nil,
:panel-width 457.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}Two entries in :groups, each with its own :color (RGBA), :xs, :ys, and :label. A :legend appeared with 2 entries. The :layout now has :legend-w 100 β space reserved on the right.
Why two entries? Because :g is a categorical column. The next section explores this mechanism in detail.
Fixed color string β single color, no legend
(def fixed-color-pose
(-> five-points
(pj/lay-point :x :y {:color "#E74C3C"})))fixed-color-poseAnd the plan:
(pj/plan fixed-color-pose){:panels
[{:coord :cartesian,
:y-domain [1.945 5.355],
:x-scale {:type :linear},
:x-domain [0.8 5.2],
:x-ticks
{:values [1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["1.0" "1.5" "2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
:categorical? false},
:col 0,
:layers
[{:mark :point,
:style {:opacity 0.75, :radius 3.0},
:size-scale nil,
:alpha-scale nil,
:groups
[{:color
[0.9058823529411765 0.2980392156862745 0.23529411764705882 1.0],
:xs #tech.v3.dataset.column<float64>[5]
:x
[1.000, 2.000, 3.000, 4.000, 5.000],
:ys #tech.v3.dataset.column<float64>[5]
:y
[2.100, 4.300, 3.000, 5.200, 4.800],
:row-indices #tech.v3.dataset.column<int64>[5]
:__row-idx
[0, 1, 2, 3, 4]}],
:y-domain [2.1 5.2],
:x-domain [1.0 5.0]}],
:y-scale {:type :linear},
:y-ticks
{:values [2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 42.5,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "y",
:alpha-legend nil,
:x-label "x",
:subtitle nil,
:panel-width 557.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}A single :groups entry with red RGBA values. No :legend, :legend-w is 0. The hex string was converted to [0.906 0.298 0.235 1.0].
Named colors and string disambiguation
CSS color names like "red" and "steelblue" also work as fixed colors:
(-> five-points
(pj/lay-point :x :y {:color "steelblue"}))This raises a question: since :color also accepts column names as strings (like "species"), how does the system decide whether "red" means the column :red or the color red?
The rule is: check the dataset first. If the string matches a column name in the dataset, it is treated as a column reference. Otherwise, it is treated as a color value β first trying hex parsing, then CSS color name lookup.
Here is the full resolution order for a string :color value:
- If the string matches a dataset column, it is a column reference (grouping)
- If it starts with
#, it is a hex color ("#E74C3C","#F00") - If it parses as hex without
#, it is a hex color ("00FF00") - If it matches a CSS color name, it is a named color (
"red","steelblue") - Otherwise, error with a helpful message
In practice, ambiguity is rare. Column names like "species" or "temperature" are not valid CSS colors, and color names like "red" are unlikely column names. When true ambiguity exists, use a keyword for the column (:red) or a hex string for the color ("#FF0000").
Verify: "red" is a fixed color when the dataset has no red column:
(def red-color-pose
(-> five-points
(pj/lay-point :x :y {:color "red"})))red-color-pose(let [plan (pj/plan red-color-pose)]
{:legend (:legend plan)
:color (:color (first (:groups (first (:layers (first (:panels plan)))))))}){:legend nil, :color [1.0 0.0 0.0 1.0]}No legend, red RGBA β treated as a fixed color, not a column.
No color β default gray
Look back at the first scatter plan above β its single :groups entry has the default color (dark gray, #333). No legend.
Grouping
The :groups entries you saw above reflect a key concept: grouping controls how data is split into independent subsets. Each group gets its own visual elements β its own set of points, its own regression line, its own density curve, its own bar in a dodged layout.
Internally, infer-grouping in resolve.clj builds the grouping vector from explicit :group and categorical color.
Grouping can be derived (from a categorical :color mapping) or explicit (via the :group aesthetic).
Categorical color implies grouping
When :color maps to a categorical column (as with colored-pose above), the data is split into one group per category. Each group gets a distinct palette color and a legend entry:
colored-pose(let [plan (pj/plan colored-pose)
layer (first (:layers (first (:panels plan))))]
{:group-count (count (:groups layer))
:group-labels (mapv :label (:groups layer))
:has-legend? (some? (:legend plan))}){:group-count 2, :group-labels ["a" "b"], :has-legend? true}Two groups, two legend entries. Each group has its own :xs, :ys, and :color.
Numeric color does not create groups
When :color maps to a numerical column, data is NOT split. Instead, each point gets an individual color from a continuous gradient. There is one group, and the legend is continuous with 20 pre-computed color stops.
(def numeric-color-pose
(-> {:x [1 2 3 4 5]
:y [2 4 3 5 4]
:val [10 20 30 40 50]}
(pj/lay-point :x :y {:color :val})))numeric-color-pose(let [plan (pj/plan numeric-color-pose)
layer (first (:layers (first (:panels plan))))]
{:group-count (count (:groups layer))
:legend-type (:type (:legend plan))
:color-stops (count (:stops (:legend plan)))}){:group-count 1, :legend-type :continuous, :color-stops 20}One group, continuous legend with 20 stops. No splitting occurred β the color is a visual encoding, not a grouping variable.
Overriding color type with :color-type
Sometimes a numeric column is really a categorical identifier β for example, subject IDs in a repeated-measures study. The inference system sees numbers and treats them as continuous, but you want discrete groups. The :color-type :categorical override tells the library to treat the column as categorical despite its numeric dtype.
This is a core principle of the library: inference provides good defaults, but the user can always override.
(def study-data
{:subject [1 1 1 2 2 2 3 3 3]
:day [1 2 3 1 2 3 1 2 3]
:score [5 7 6 3 4 5 8 9 7]})Without override β one group, continuous gradient:
(def study-continuous-pose
(-> study-data
(pj/lay-line :day :score {:color :subject})))study-continuous-pose(let [plan (pj/plan study-continuous-pose)
layer (first (:layers (first (:panels plan))))]
{:group-count (count (:groups layer))
:legend-type (:type (:legend plan))})Warning: [:line] with a numeric :color render as a single line per group. The color column picks one representative color from the gradient legend; use :color-type :categorical to split into multiple lines.
{:group-count 1, :legend-type :continuous}With :color-type :categorical β three groups, one per subject:
(def study-categorical-pose
(-> study-data
(pj/lay-line :day :score {:color :subject
:color-type :categorical})))study-categorical-pose(let [plan (pj/plan study-categorical-pose)
layer (first (:layers (first (:panels plan))))]
{:group-count (count (:groups layer))
:legend-entries (count (:entries (:legend plan)))}){:group-count 3, :legend-entries 3}The same data, the same columns β but :color-type :categorical changes inference from βone gradientβ to βthree distinct groups.β This affects grouping, line splitting, legend style, and palette assignment. The rendered plots look completely different:
(-> {:subject [1 1 1 2 2 2 3 3 3]
:day [1 2 3 1 2 3 1 2 3]
:score [5 7 6 3 4 5 8 9 7]}
(pj/lay-line :day :score {:color :subject
:color-type :categorical})
pj/lay-point
(pj/options {:title "Scores by Subject (categorical override)"}))Explicit grouping with :group
The :group aesthetic splits data into groups without assigning distinct colors or creating a legend. This is useful when you want per-group statistics but uniform appearance.
(def grouped-data
{:x [1 2 3 4 5 6]
:y [3 5 4 7 6 8]
:g ["a" "a" "a" "b" "b" "b"]})(def explicit-group-pose
(-> grouped-data
(pj/lay-point :x :y {:group :g})))explicit-group-pose(let [plan (pj/plan explicit-group-pose)
layer (first (:layers (first (:panels plan))))]
{:group-count (count (:groups layer))
:has-legend? (some? (:legend plan))}){:group-count 2, :has-legend? false}Two groups, but no legend and no color differentiation. Use :group when you need separate statistical fits but want a uniform visual style.
What grouping affects
Grouping determines how statistical transformations operate. Without grouping, (pj/lay-smooth {:stat :linear-model}) (linear model) fits one regression line through all the data. With grouping, it fits one line per group.
One regression line β no grouping:
(-> grouped-data
(pj/pose :x :y)
pj/lay-point
(pj/lay-smooth {:stat :linear-model}))Two regression lines β grouped by color:
(-> grouped-data
(pj/pose :x :y {:color :g})
pj/lay-point
(pj/lay-smooth {:stat :linear-model}))The same applies to other statistics: density curves, LOESS smoothers, boxplots, and dodge/stack positioning all operate per group.
Layer Type
When you use pj/pose without an explicit pj/lay-* call, Plotje infers the layer type β a mark + stat bundle β from the column types of the referenced columns. Internally, infer-layer-type in resolve.clj applies these rules.
Single-column cases
| Column type | Inferred | Mark + stat |
|---|---|---|
| numerical | histogram | :bar + :bin |
| temporal | histogram (over epoch-ms, with calendar-aware ticks) | :bar + :bin |
| categorical | bar chart of category counts | :rect + :count |
Two-column cases
| x type | y type | Inferred | Mark + stat |
|---|---|---|---|
| numerical | numerical | scatter | :point + :identity |
| temporal | numerical | time-series line | :line + :identity |
| categorical | numerical | boxplot (vertical) | :boxplot + :boxplot |
| numerical | categorical | boxplot (horizontal) | :boxplot + :boxplot |
| any other pair | scatter (fallback) | :point + :identity |
Fallback pairs include temporal x + categorical y, categorical x + categorical y, and temporal x + temporal y. These are rarer in practice, and giving them a dedicated inference is deferred. You can always override with an explicit pj/lay-* call; the inferred layer type is only a default.
When you use pj/lay-point, pj/lay-histogram, etc., the layer typeβs stat takes precedence β column-type inference is bypassed.
A single numerical column produces a histogram:
(def hist-pose
(-> five-points
(pj/pose :x)))hist-poseThe plan shows the inferred layer type:
(pj/plan hist-pose){:panels
[{:coord :cartesian,
:y-domain [-0.1 2.1],
:x-scale {:type :linear},
:x-domain [0.8 5.2],
:x-ticks
{:values [1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["1.0" "1.5" "2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
:categorical? false},
:col 0,
:layers
[{:mark :bar,
:style {:opacity 0.85},
:groups
[{:color [0.2 0.2 0.2 1.0],
:bars
[{:lo 1.0, :hi 2.0, :count 1}
{:lo 2.0, :hi 3.0, :count 1}
{:lo 3.0, :hi 4.0, :count 1}
{:lo 4.0, :hi 5.0, :count 2}]}],
:y-domain [0 2],
:x-domain [1.0 5.0]}],
:y-scale {:type :linear},
:y-ticks
{:values [-0.0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 1.8 2.0],
:labels
["0.0"
"0.2"
"0.4"
"0.6"
"0.8"
"1.0"
"1.2"
"1.4"
"1.6"
"1.8"
"2.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.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 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label nil,
:alpha-legend nil,
:x-label "x",
:subtitle nil,
:panel-width 583.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}The layer mark is :bar β the layer data contains :bins with :x0, :x1, :count β the result of the :bin stat.
A single temporal column also becomes a histogram, binned over epoch-milliseconds with calendar-aware tick labels:
(def temporal-hist-pose
(-> {:date [#inst "2024-01-01" #inst "2024-02-01" #inst "2024-03-01"
#inst "2024-04-01" #inst "2024-05-01"]}
(pj/pose :date)))temporal-hist-poseThe plan shows the inferred layer type:
(pj/plan temporal-hist-pose){:panels
[{:coord :cartesian,
:y-domain [-0.1 2.1],
:x-scale {:type :linear},
:x-domain [1.70354448E12 1.71504432E12],
:x-ticks
{:values
[1.7041536E12
1.7052768E12
1.7064E12
1.7075232E12
1.7086464E12
1.7097696E12
1.7108928E12
1.712016E12
1.7131392E12
1.7142624E12],
:labels
["Jan-02"
"Jan-15"
"Jan-28"
"Feb-10"
"Feb-23"
"Mar-07"
"Mar-20"
"Apr-02"
"Apr-15"
"Apr-28"],
:categorical? false},
:col 0,
:layers
[{:mark :bar,
:style {:opacity 0.85},
:groups
[{:color [0.2 0.2 0.2 1.0],
:bars
[{:lo 1.7040672E12, :hi 1.7066808E12, :count 1}
{:lo 1.7066808E12, :hi 1.7092944E12, :count 2}
{:lo 1.7092944E12, :hi 1.711908E12, :count 0}
{:lo 1.711908E12, :hi 1.7145216E12, :count 2}]}],
:y-domain [0 2],
:x-domain [1.7040672E12 1.7145216E12]}],
:y-scale {:type :linear},
:y-ticks
{:values [-0.0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 1.8 2.0],
:labels
["0.0"
"0.2"
"0.4"
"0.6"
"0.8"
"1.0"
"1.2"
"1.4"
"1.6"
"1.8"
"2.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.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 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label nil,
:alpha-legend nil,
:x-label "date",
:subtitle nil,
:panel-width 583.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}A single categorical column produces a bar chart of counts:
(def count-pose
(-> animals
(pj/pose :animal)))count-poseThe plan shows the inferred layer type:
(pj/plan count-pose){:panels
[{:coord :cartesian,
:y-domain [-0.05 1.05],
:x-scale {:type :linear},
:x-domain ["cat" "dog" "bird" "fish"],
:x-ticks
{:values ["cat" "dog" "bird" "fish"],
:labels ["cat" "dog" "bird" "fish"],
:categorical? true},
:col 0,
:layers
[{:mark :rect,
:style {:opacity 0.85},
:position :dodge,
:categories ["cat" "dog" "bird" "fish"],
:groups
[{:color [0.2 0.2 0.2 1.0],
:label "",
:counts
[{:category "cat", :count 1}
{:category "dog", :count 1}
{:category "bird", :count 1}
{:category "fish", :count 1}],
:dodge-idx 0}],
:y-domain [0 1],
:x-domain ("cat" "dog" "bird" "fish"),
:dodge-ctx {:n-groups 1}}],
:y-scale {:type :linear},
:y-ticks
{:values [-0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0],
:labels
["0.0"
"0.1"
"0.2"
"0.3"
"0.4"
"0.5"
"0.6"
"0.7"
"0.8"
"0.9"
"1.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.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 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label nil,
:alpha-legend nil,
:x-label "animal",
:subtitle nil,
:panel-width 583.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}Mark is :rect with :counts β the :count stat tallied each of the 4 categories.
Two numerical columns produce a scatter (the chapterβs opening scatter-pose is such a pose):
(def num-num-pose
(-> five-points (pj/pose :x :y)))num-num-poseThe plan shows the inferred layer type:
(pj/plan num-num-pose){:panels
[{:coord :cartesian,
:y-domain [1.945 5.355],
:x-scale {:type :linear},
:x-domain [0.8 5.2],
:x-ticks
{:values [1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["1.0" "1.5" "2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
: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>[5]
:x
[1.000, 2.000, 3.000, 4.000, 5.000],
:ys #tech.v3.dataset.column<float64>[5]
:y
[2.100, 4.300, 3.000, 5.200, 4.800],
:row-indices #tech.v3.dataset.column<int64>[5]
:__row-idx
[0, 1, 2, 3, 4]}],
:y-domain [2.1 5.2],
:x-domain [1.0 5.0]}],
:y-scale {:type :linear},
:y-ticks
{:values [2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 42.5,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "y",
:alpha-legend nil,
:x-label "x",
:subtitle nil,
:panel-width 557.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}A temporal x with a numerical y infers a time-series line. Row order is preserved, so pre-sort temporal data to avoid zigzag:
(def ts-line-pose
(-> {:date [#inst "2024-01-01" #inst "2024-02-01" #inst "2024-03-01"]
:val [10 25 18]}
(pj/pose :date :val)))ts-line-poseThe plan shows the inferred layer type:
(pj/plan ts-line-pose){:panels
[{:coord :cartesian,
:y-domain [9.25 25.75],
:x-scale {:type :linear},
:x-domain [1.703808E12 1.7095104E12],
:x-ticks
{:values
[1.7041536E12
1.704672E12
1.7051904E12
1.7057088E12
1.7062272E12
1.7067456E12
1.707264E12
1.7077824E12
1.7083008E12
1.7088192E12],
:labels
["Jan-02"
"Jan-08"
"Jan-14"
"Jan-20"
"Jan-26"
"Feb-01"
"Feb-07"
"Feb-13"
"Feb-19"
"Feb-25"],
:categorical? false},
:col 0,
:layers
[{:mark :line,
:style {:stroke-width 2.5, :opacity 1.0},
:groups
[{:color [0.2 0.2 0.2 1.0],
:label "",
:xs #tech.v3.dataset.column<float64>[3]
:date
[1.704E+12, 1.707E+12, 1.709E+12],
:ys #tech.v3.dataset.column<int64>[3]
:val
[10, 25, 18]}],
:y-domain [10 25],
:x-domain [1.7040672E12 1.7092512E12]}],
:y-scale {:type :linear},
:y-ticks
{:values [10.0 12.0 14.0 16.0 18.0 20.0 22.0 24.0],
:labels ["10" "12" "14" "16" "18" "20" "22" "24"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 38.0,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "val",
:alpha-legend nil,
:x-label "date",
:subtitle nil,
:panel-width 562.0,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}A categorical x with a numerical y infers a boxplot β the default for summarizing a distribution across groups:
(def boxplot-pose
(-> {:species ["a" "a" "a" "b" "b" "b" "c" "c" "c"]
:val [8 10 12 18 20 22 14 15 17]}
(pj/pose :species :val)))boxplot-poseThe plan shows the inferred layer type:
(pj/plan boxplot-pose){:panels
[{:coord :cartesian,
:y-domain [7.3 22.7],
:x-scale {:type :linear},
:x-domain ["a" "b" "c"],
:x-ticks
{:values ["a" "b" "c"], :labels ["a" "b" "c"], :categorical? true},
:col 0,
:layers
[{:mark :boxplot,
:style {:box-width 0.6, :stroke-width 1.5, :opacity 1.0},
:position :dodge,
:color-categories nil,
:boxes
[{:category "a",
:color [0.2 0.2 0.2 1.0],
:median 10.0,
:q1 9.0,
:q3 11.0,
:whisker-lo 8.0,
:whisker-hi 12.0,
:dodge-idx 0}
{:category "b",
:color [0.2 0.2 0.2 1.0],
:median 20.0,
:q1 19.0,
:q3 21.0,
:whisker-lo 18.0,
:whisker-hi 22.0,
:dodge-idx 0}
{:category "c",
:color [0.2 0.2 0.2 1.0],
:median 15.0,
:q1 14.5,
:q3 16.0,
:whisker-lo 14.0,
:whisker-hi 17.0,
:dodge-idx 0}],
:y-domain [8 22],
:x-domain ("a" "b" "c"),
:dodge-ctx {:n-groups 1}}],
:y-scale {:type :linear},
:y-ticks
{:values [8.0 10.0 12.0 14.0 16.0 18.0 20.0 22.0],
:labels ["8" "10" "12" "14" "16" "18" "20" "22"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 38.0,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "val",
:alpha-legend nil,
:x-label "species",
:subtitle nil,
:panel-width 562.0,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}A numerical x with a categorical y infers a horizontal boxplot β the same summary laid out with the category axis on y:
(def horizontal-boxplot-pose
(-> {:val [8 10 12 18 20 22 14 15 17]
:species ["a" "a" "a" "b" "b" "b" "c" "c" "c"]}
(pj/pose :val :species)))horizontal-boxplot-poseThe plan shows the inferred layer type:
(pj/plan horizontal-boxplot-pose){:panels
[{:coord :cartesian,
:y-domain ["a" "b" "c"],
:x-scale {:type :linear},
:x-domain [7.3 22.7],
:x-ticks
{:values [8.0 10.0 12.0 14.0 16.0 18.0 20.0 22.0],
:labels ["8" "10" "12" "14" "16" "18" "20" "22"],
:categorical? false},
:col 0,
:layers
[{:mark :boxplot,
:style {:box-width 0.6, :stroke-width 1.5, :opacity 1.0},
:position :dodge,
:color-categories nil,
:boxes
[{:category "a",
:color [0.2 0.2 0.2 1.0],
:median 10.0,
:q1 9.0,
:q3 11.0,
:whisker-lo 8.0,
:whisker-hi 12.0,
:dodge-idx 0}
{:category "b",
:color [0.2 0.2 0.2 1.0],
:median 20.0,
:q1 19.0,
:q3 21.0,
:whisker-lo 18.0,
:whisker-hi 22.0,
:dodge-idx 0}
{:category "c",
:color [0.2 0.2 0.2 1.0],
:median 15.0,
:q1 14.5,
:q3 16.0,
:whisker-lo 14.0,
:whisker-hi 17.0,
:dodge-idx 0}],
:y-domain ("a" "b" "c"),
:x-domain [8 22],
:dodge-ctx {:n-groups 1}}],
:y-scale {:type :linear},
:y-ticks
{:values ["a" "b" "c"], :labels ["a" "b" "c"], :categorical? true},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 38.0,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "species",
:alpha-legend nil,
:x-label "val",
:subtitle nil,
:panel-width 562.0,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}Domains
Numerical domains extend 5% beyond the data range so points arenβt clipped at the edges. Internally, pad-domain in scale.clj computes this padding.
scatter-pose(let [plan (pj/plan scatter-pose)
p (first (:panels plan))]
{:x-domain (:x-domain p)
:data-range [1.0 5.0]
:padding-each-side (* 0.05 (- 5.0 1.0))}){:x-domain [0.8 5.2], :data-range [1.0 5.0], :padding-each-side 0.2}The domain [0.8, 5.2] = data range [1.0, 5.0] +/- 0.2 (5% of 4.0).
Special domain rules apply in certain contexts:
Bar chart y-domains always include zero:
bar-pose(let [plan (pj/plan bar-pose)
p (first (:panels plan))]
{:y-domain (:y-domain p)}){:y-domain [-0.75 15.75]}Percentage-filled layers normalize the y-domain to [0.0, 1.0]:
(def fill-pose
(-> {:x ["a" "a" "b" "b"]
:g ["m" "n" "m" "n"]}
(pj/lay-bar :x {:position :fill :color :g})))fill-pose(:y-domain (first (:panels (pj/plan fill-pose))))[0.0 1.0]The y-domain is exactly [0.0, 1.0] β each category sums to 100%.
Multi-layer plots merge domains across layers β see βMulti-Layer Plansβ below.
Ticks
Once domains are computed, Plotje selects βniceβ round tick values. The logic depends on the scale type:
Linear β wadogo selects ticks at round intervals (1, 2, 2.5, 5, β¦)
Log β 1-2-5 nice numbers: powers of 10 when they give at least 3 ticks, otherwise intermediates at 1-2-5 or 1-2-3-5 multiples per decade
Categorical β tick at each category, in order of appearance
Temporal β calendar-aware snapping (year, month, day, hour) with adaptive formatting
Linear ticks for the scatter example:
scatter-pose(let [plan (pj/plan scatter-pose)
p (first (:panels plan))]
{:x-tick-values (:values (:x-ticks p))
:x-tick-labels (:labels (:x-ticks p))}){:x-tick-values [1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:x-tick-labels ["1.0" "1.5" "2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"]}Nine ticks from 1.0 to 5.0 at 0.5 intervals β round and readable.
Log ticks for a multi-decade range:
(def log-scale-pose
(-> {:x [0.1 1.0 10.0 100.0 1000.0]
:y [5 10 15 20 25]}
(pj/lay-point :x :y)
(pj/scale :x :log)))log-scale-pose(let [plan (pj/plan log-scale-pose)
p (first (:panels plan))]
{:tick-values (:values (:x-ticks p))
:tick-labels (:labels (:x-ticks p))}){:tick-values [0.1 1.0 10.0 100.0 1000.0],
:tick-labels ["0.1" "1" "10" "100" "1000"]}Five ticks at exact powers of 10 β no irrational intermediates. Whole numbers display without decimals, sub-1 values use minimal decimal places.
Categorical ticks match domain order:
bar-pose(let [plan (pj/plan bar-pose)
p (first (:panels plan))]
(:values (:x-ticks p)))["cat" "dog" "bird" "fish"]Axis Labels
Labels come from column names. Underscores and hyphens become spaces. Internally, resolve-labels in plan.clj handles this.
(def iris-label-pose
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width)))iris-label-pose(let [plan (pj/plan iris-label-pose)]
{:x-label (:x-label plan)
:y-label (:y-label plan)}){:x-label "sepal length", :y-label "sepal width"}When only one column is specified, the y-axis shows computed counts. The system omits the y-label since it would repeat the column name:
(def x-only-pose
(-> five-points (pj/pose :x)))x-only-pose(let [plan (pj/plan x-only-pose)]
{:x-label (:x-label plan)
:y-label (:y-label plan)}){:x-label "x", :y-label nil}Explicit labels override inference:
(def explicit-label-pose
(-> five-points
(pj/lay-point :x :y)
(pj/options {:x-label "Length (cm)" :y-label "Width (cm)"})))explicit-label-pose(let [plan (pj/plan explicit-label-pose)]
{:x-label (:x-label plan)
:y-label (:y-label plan)}){:x-label "Length (cm)", :y-label "Width (cm)"}Legends
A legend appears when a column is mapped to color. Internally, build-legend in plan.clj constructs the legend from the collected color information. Three cases:
A categorical color mapping produces a discrete legend with one entry per category:
colored-pose(:legend (pj/plan colored-pose)){:title :g,
:entries
[{:label "a",
:color
[0.8941176470588236 0.10196078431372549 0.10980392156862745 1.0]}
{:label "b",
:color
[0.21568627450980393 0.49411764705882355 0.7215686274509804 1.0]}]}Title is the column name. Each entry has a :label and :color (RGBA).
No color mapping means no legend:
scatter-pose(:legend (pj/plan scatter-pose))nilA fixed color string also suppresses the legend:
fixed-color-pose(:legend (pj/plan fixed-color-pose))nilA numeric color mapping produces a continuous legend (gradient bar):
(def continuous-color-pose
(-> {:x [1 2 3] :y [4 5 6] :val [10 20 30]}
(pj/lay-point :x :y {:color :val})))continuous-color-pose(:legend (pj/plan continuous-color-pose)){:title :val,
:type :continuous,
:min 10,
:max 30,
:color-scale nil,
:stops
[{:t 0.0,
:color
[0.07450980392156863 0.16862745098039217 0.2627450980392157 1.0]}
{:t 0.05263157894736842,
:color
[0.08833849329205366 0.19628482972136224 0.2998968008255934 1.0]}
{:t 0.10526315789473684,
:color
[0.1021671826625387 0.22394220846233232 0.3370485036119711 1.0]}
{:t 0.15789473684210525,
:color
[0.11599587203302374 0.2515995872033024 0.3742002063983488 1.0]}
{:t 0.21052631578947367,
:color
[0.1298245614035088 0.27925696594427246 0.4113519091847265 1.0]}
{:t 0.2631578947368421,
:color
[0.14365325077399382 0.30691434468524253 0.4485036119711042 1.0]}
{:t 0.3157894736842105,
:color
[0.15748194014447883 0.33457172342621255 0.4856553147574819 1.0]}
{:t 0.3684210526315789,
:color
[0.17131062951496387 0.3622291021671826 0.5228070175438597 1.0]}
{:t 0.42105263157894735,
:color
[0.1851393188854489 0.3898864809081527 0.5599587203302373 1.0]}
{:t 0.47368421052631576,
:color
[0.19896800825593394 0.41754385964912283 0.597110423116615 1.0]}
{:t 0.5263157894736842,
:color
[0.21279669762641898 0.4452012383900929 0.6342621259029928 1.0]}
{:t 0.5789473684210527,
:color
[0.22662538699690402 0.472858617131063 0.6714138286893705 1.0]}
{:t 0.631578947368421,
:color
[0.24045407636738905 0.500515995872033 0.7085655314757482 1.0]}
{:t 0.6842105263157895,
:color
[0.25428276573787406 0.5281733746130031 0.7457172342621259 1.0]}
{:t 0.7368421052631579,
:color
[0.2681114551083591 0.5558307533539731 0.7828689370485036 1.0]}
{:t 0.7894736842105263,
:color
[0.28194014447884413 0.5834881320949432 0.8200206398348814 1.0]}
{:t 0.8421052631578947,
:color
[0.29576883384932917 0.6111455108359133 0.857172342621259 1.0]}
{:t 0.8947368421052632,
:color
[0.3095975232198142 0.6388028895768834 0.8943240454076368 1.0]}
{:t 0.9473684210526315,
:color
[0.3234262125902993 0.6664602683178534 0.9314757481940144 1.0]}
{:t 1.0,
:color
[0.33725490196078434 0.6941176470588235 0.9686274509803922 1.0]}]}Size Legend
When :size maps to a numerical column, a size legend shows graduated circles spanning the data range. Internally, build-size-legend in plan.clj generates five entries with proportional radii.
(def size-legend-pose
(-> {:x [1 2 3 4 5] :y [1 2 3 4 5] :s [10 20 30 40 50]}
(pj/lay-point :x :y {:size :s})))size-legend-pose(:size-legend (pj/plan size-legend-pose)){:title :s,
:type :size,
:min 10,
:max 50,
:scale-type :linear,
:entries
[{:value 10.0, :radius 2.0}
{:value 20.0, :radius 3.5}
{:value 30.0, :radius 5.0}
{:value 40.0, :radius 6.5}
{:value 50.0, :radius 8.0}]}Each entry has a :value and :radius. No size mapping means no size legend:
scatter-pose(:size-legend (pj/plan scatter-pose))nilAlpha Legend
When :alpha maps to a numerical column, an alpha legend shows graduated opacity squares. Internally, build-alpha-legend in plan.clj asks for about five nice 1/2/5 breaks; the exact count depends on the range (here the range [0.1, 0.9] yields four).
(def alpha-legend-pose
(-> {:x [1 2 3 4 5] :y [1 2 3 4 5] :a [0.1 0.3 0.5 0.7 0.9]}
(pj/lay-point :x :y {:alpha :a})))alpha-legend-pose(:alpha-legend (pj/plan alpha-legend-pose)){:title :a,
:type :alpha,
:min 0.1,
:max 0.9,
:scale-type :linear,
:entries
[{:value 0.2, :alpha 0.30000000000000004}
{:value 0.4, :alpha 0.5}
{:value 0.6, :alpha 0.7}
{:value 0.8, :alpha 0.9000000000000001}]}No alpha mapping means no alpha legend:
scatter-pose(:alpha-legend (pj/plan scatter-pose))nilLayout
The :layout map adjusts padding based on what elements are present. Internally, compute-layout-dims in plan.clj calculates the space needed for titles, labels, and legends.
Compare a bare plot to one with title, labels, and legend:
scatter-pose(def full-layout-pose
(-> {:x [1 2 3 4 5 6]
:y [3 5 4 7 6 8]
:g ["a" "a" "a" "b" "b" "b"]}
(pj/lay-point :x :y {:color :g})
(pj/options {:title "My Plot"})))full-layout-pose(let [bare (pj/plan scatter-pose)
full (pj/plan full-layout-pose)]
{:bare-title-pad (get-in bare [:layout :title-pad])
:full-title-pad (get-in full [:layout :title-pad])
:bare-legend-w (get-in bare [:layout :legend-w])
:full-legend-w (get-in full [:layout :legend-w])}){:bare-title-pad 0,
:full-title-pad 33,
:bare-legend-w 0,
:full-legend-w 100}The bare plot has zero title padding and zero legend width. The full plot adds padding for the title and 100 pixels for the legend.
Layout type is also inferred from the pose structure:
- A single panel is
:single - A facet grid (
:facet-rowor:facet-col) is:facet-grid - Multiple x-y pairs (scatter plot matrix) are
:multi-variable
scatter-pose(:layout-type (pj/plan scatter-pose)):singleCoordinate Flipping
Setting :coord :flip swaps axes in the plan. The layer data stays the same β the panel-level domains and ticks are swapped. Internally, make-coord in coord.clj handles the transformation.
(def normal-pose
(-> animals
(pj/lay-value-bar :animal :count)))normal-pose(def flip-pose
(-> animals
(pj/lay-value-bar :animal :count)
(pj/coord :flip)))flip-pose(let [np (first (:panels (pj/plan normal-pose)))
fp (first (:panels (pj/plan flip-pose)))]
{:normal {:x-categorical? (:categorical? (:x-ticks np))
:y-categorical? (:categorical? (:y-ticks np))}
:flipped {:x-categorical? (:categorical? (:x-ticks fp))
:y-categorical? (:categorical? (:y-ticks fp))}}){:normal {:x-categorical? true, :y-categorical? false},
:flipped {:x-categorical? false, :y-categorical? true}}The categorical axis moved from x to y.
Labels are also swapped β the x-label and y-label follow their visual axis, not the data axis:
(def flipped-labels-pose
(-> five-points
(pj/lay-point :x :y)
(pj/coord :flip)))flipped-labels-pose(let [plan (pj/plan flipped-labels-pose)]
{:x-label (:x-label plan)
:y-label (:y-label plan)}){:x-label "y", :y-label "x"}After flipping, the visual x-axis shows βyβ and the visual y-axis shows βxβ β labels track the visual axes.
Polar coordinates (:coord :polar) are covered separately β see the Polar Coordinates chapter for rose charts, radial bars, and related plots.
Multi-Layer Plans
When multiple layers share a panel, their domains are merged:
(def multi-pose
(-> five-points
(pj/pose :x :y)
pj/lay-point
(pj/lay-smooth {:stat :linear-model})))multi-poseAnd the plan:
(pj/plan multi-pose){:panels
[{:coord :cartesian,
:y-domain [1.945 5.355],
:x-scale {:type :linear},
:x-domain [0.8 5.2],
:x-ticks
{:values [1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["1.0" "1.5" "2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
: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>[5]
:x
[1.000, 2.000, 3.000, 4.000, 5.000],
:ys #tech.v3.dataset.column<float64>[5]
:y
[2.100, 4.300, 3.000, 5.200, 4.800],
:row-indices #tech.v3.dataset.column<int64>[5]
:__row-idx
[0, 1, 2, 3, 4]}],
:y-domain [2.1 5.2],
:x-domain [1.0 5.0]}
{:mark :line,
:style {:stroke-width 2.5, :opacity 1.0},
:groups
[{:color [0.2 0.2 0.2 1.0],
:label "",
:x1 1.0,
:y1 2.6200000000000014,
:x2 5.0,
:y2 5.139999999999999}],
:y-domain [2.1 5.2],
:x-domain [1.0 5.0]}],
:y-scale {:type :linear},
:y-ticks
{:values [2.0 2.5 3.0 3.5 4.0 4.5 5.0],
:labels ["2.0" "2.5" "3.0" "3.5" "4.0" "4.5" "5.0"],
:categorical? false},
:row 0}],
:width 600,
:height 400,
:caption nil,
:total-width 600.0,
:legend-position :none,
:layout-type :single,
:layout
{:subtitle-pad 0,
:legend-w 0,
:caption-pad 0,
:y-label-pad 42.5,
:legend-h 0.0,
:title-pad 0,
:strip-h 0,
:x-label-pad 38,
:strip-w 0.0},
:grid {:rows 1, :cols 1},
:legend nil,
:panel-height 362.0,
:title nil,
:y-label "y",
:alpha-legend nil,
:x-label "x",
:subtitle nil,
:panel-width 557.5,
:size-legend nil,
:total-height 400.0,
:tooltip nil,
:margin 10}Two layers β one :point, one :line β sharing the same domain. The :line layer has :mark :line and its groups contain :polyline-xs and :polyline-ys β the regression curve.
Resolution Overview
All of the inference rules above feed into draft->plan, which orchestrates a resolution pipeline. The diagram below shows the key steps and their data dependencies:
(infer-column-types)"] POSE --> AE["Aesthetics
(resolve-aesthetics)"] CT --> GR["Grouping
(infer-grouping)"] AE --> GR CT --> ME["Layer type
(infer-layer-type)"] GR --> STATS["Statistics
(compute-stat)"] ME --> STATS STATS --> DOM["Domains
(collect-domain + pad-domain)"] DOM --> TK["Ticks
(compute-ticks)"] POSE --> LBL["Labels
(resolve-labels)"] AE --> LEG["Color Legend
(build-legend)"] AE --> SLEG["Size Legend
(build-size-legend)"] AE --> ALEG["Alpha Legend
(build-alpha-legend)"] DOM --> LAYOUT["Layout
(compute-layout-dims)"] LBL --> LAYOUT LEG --> LAYOUT SLEG --> LAYOUT ALEG --> LAYOUT DOM --> PLAN["Plan"] TK --> PLAN LBL --> PLAN LEG --> PLAN SLEG --> PLAN ALEG --> PLAN LAYOUT --> PLAN STATS --> PLAN style POSE fill:#e8f5e9 style PLAN fill:#fff3e0 style STATS fill:#e3f2fd style DOM fill:#e3f2fd
Each box corresponds to a named function in the codebase. The top four boxes β Column Types, Aesthetics, Grouping, and Layer type β are the per-leaf inference steps (in resolve.clj). The remaining boxes are the plan-level orchestration steps (in plan.clj and scale.clj).
Whatβs Next
- Layer Types β the full registry of marks, stats, and positions that inference selects from
- Scatter Plots β see inference in action with the most common chart type