27  Architecture

Plotje has a five-stage pipeline. You compose a pose – a declarative description of data, mappings, and layers – that flattens into a draft automatically.

This notebook traces a small example through every stage, explains the plan boundary, and shows the namespace structure.

(ns plotje-book.architecture
  (:require
   ;; Kindly -- notebook rendering protocol
   [scicloj.kindly.v4.kind :as kind]
   ;; Rdatasets -- standard datasets
   [scicloj.metamorph.ml.rdatasets :as rdatasets]
   ;; Plotje -- composable plotting
   [scicloj.plotje.api :as pj]
   ;; Pose substrate -- leaf->draft, resolve-tree
   [scicloj.plotje.impl.pose :as pose-impl]
   ;; Plan pipeline -- draft->plan, domains, ticks, legends, layout
   [scicloj.plotje.impl.plan :as plan-impl]
   ;; Malli schema validation
   [scicloj.plotje.impl.plan-schema :as ss]))

Pipeline Overview

graph LR B["Pose
(composable API)"] -->|pose->draft| D["Draft
(flat maps)"] D -->|draft->plan| P["Plan
(data-space)"] P -->|scales + coords| M["Membrane
(drawing primitives)"] M -->|tree walk| F["Plot
(output)"] style B fill:#d1c4e9 style D fill:#e8f5e9 style P fill:#fff3e0 style M fill:#e3f2fd style F fill:#fce4ec
  • Pose – a composable description of data, mappings, and layers. Built by pj/pose, pj/lay-*, pj/options, pj/facet, pj/arrange, pj/scale, and pj/coord. No computation has happened yet.

  • Draft – a flat vector of maps, one per applicable layer-and-leaf combination with scope merged. Each map has :data, :x, :y, :mark, :stat, and aesthetic keys. Produced by pj/draft.

  • Plan – fully resolved geometry in data space (domains, ticks, legends, computed shapes), as plain Clojure maps and dtype-next buffers. Produced by pj/plan. No rendering primitives yet.

  • Membrane – positioned drawing primitives (Translate, WithColor, Path, Label, …). Produced by pj/plan->membrane.

  • Plot – final output (SVG hiccup or BufferedImage). Produced by pj/membrane->plot walking the membrane tree.

Most users only interact with the pose stage and never need to think about the others. The stages below matter when you are debugging unexpected output, building a custom renderer, or extending the library.

Pipeline Trace

Let’s trace a small example through all five stages, inspecting the intermediate values at each step.

(def trace-data
  {:x [1 2 3 4 5]
   :y [2 4 3 5 4]
   :g [:a :a :b :b :b]})

Pose

The user composes a pose by threading data through composable functions. The pose records what to plot without doing any computation.

(def trace-pose
  (-> trace-data
      (pj/lay-point :x :y {:color :g})))

A pose is a plain Clojure map. The fields below are what you see while inspecting the threaded value:

  • :data – the dataset (coerced to Tablecloth)

  • :mapping – mappings that flow into every layer on this pose

  • :layers – layers placed on this pose

  • :poses – sub-poses; a leaf has none

  • :opts – plot-level options (title, width, etc.)

(pj/pose? trace-pose)
true

Because a leaf pose has no sub-poses, this is a leaf:

(pose-impl/leaf? trace-pose)
true

The mapping carries the position aesthetics (from the positional :x / :y arguments); the color aesthetic (from the options map) rides on the layer so a subsequent pj/lay-* with different options does not disturb it:

(:mapping trace-pose)
{:x :x, :y :y}
(get-in trace-pose [:layers 0 :layer-type])
:point

The :color mapping lives on the layer’s own :mapping:

(get-in trace-pose [:layers 0 :mapping :color])
:g

Draft

pj/draft flattens the pose into a vector of maps. Each map merges pose-level mappings, leaf mappings, and layer details into one flat map with :data, :x, :y, :mark, etc.

(def trace-draft
  (pj/draft trace-pose))
(count trace-draft)
1
(select-keys (first trace-draft) [:x :y :mark :color])
{:x :x, :y :y, :mark :point, :color :g}

Plan

draft->plan converts the draft into a plan – a pure-data map with data-space geometry, resolved colors, computed domains, and tick info. The values are still in data space.

(def trace-plan
  (plan-impl/draft->plan trace-draft {}))
trace-plan
{:panels
 [{:coord :cartesian,
   :y-domain [1.85 5.15],
   :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.8941176470588236
        0.10196078431372549
        0.10980392156862745
        1.0],
       :xs #tech.v3.dataset.column<int64>[2]
:x
[1, 2],
       :ys #tech.v3.dataset.column<int64>[2]
:y
[2, 4],
       :label "a",
       :row-indices #tech.v3.dataset.column<int64>[2]
:__row-idx
[0, 1]}
      {:color
       [0.21568627450980393
        0.49411764705882355
        0.7215686274509804
        1.0],
       :xs #tech.v3.dataset.column<int64>[3]
:x
[3, 4, 5],
       :ys #tech.v3.dataset.column<int64>[3]
:y
[3, 5, 4],
       :label "b",
       :row-indices #tech.v3.dataset.column<int64>[3]
:__row-idx
[2, 3, 4]}],
     :y-domain [2 5],
     :x-domain [1 5]}],
   :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 :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}

The plan validates against a Malli schema:

(ss/valid? trace-plan)
true

Membrane

plan->membrane converts the plan into a tree of membrane drawing primitives laid out for the rendered plot.

(def trace-membrane (pj/plan->membrane trace-plan))
trace-membrane
[{:x 12,
  :y 181.0,
  :drawable
  {:degrees -90,
   :drawable
   {:color [0.2 0.2 0.2 1.0],
    :drawables
    ({:text "y",
      :font {:name nil, :size 13, :weight nil, :width nil, :slant nil},
      :text-anchor "middle"})}}}
 {:x 271.25,
  :y 382.0,
  :drawable
  {:color [0.2 0.2 0.2 1.0],
   :drawables
   ({:text "x",
     :font {:name nil, :size 13, :weight nil, :width nil, :slant nil},
     :text-anchor "middle"})}}
 {:x 510.0,
  :y 2,
  :drawable
  {:color [0.2 0.2 0.2 1.0],
   :drawables
   ({:text "g",
     :font
     {:name nil, :size 11, :weight nil, :width nil, :slant nil}})}}
 {:x 510.0,
  :y 20,
  :drawable
  [{:x 0,
    :y 0,
    :drawable
    {:color
     [0.8941176470588236 0.10196078431372549 0.10980392156862745 1.0],
     :drawables
     ({:style :membrane.ui/style-fill,
       :drawables [{:width 8, :height 8, :border-radius 4.0}]})}}
   {:x 12,
    :y 0,
    :drawable
    {:color [0.2 0.2 0.2 1.0],
     :drawables
     ({:text "a",
       :font
       {:name nil, :size 10, :weight nil, :width nil, :slant nil}})}}]}
 {:x 510.0,
  :y 36,
  :drawable
  [{:x 0,
    :y 0,
    :drawable
    {:color
     [0.21568627450980393 0.49411764705882355 0.7215686274509804 1.0],
     :drawables
     ({:style :membrane.ui/style-fill,
       :drawables [{:width 8, :height 8, :border-radius 4.0}]})}}
   {:x 12,
    :y 0,
    :drawable
    {:color [0.2 0.2 0.2 1.0],
     :drawables
     ({:text "b",
       :font
       {:name nil, :size 10, :weight nil, :width nil, :slant nil}})}}]}
 {:x 52.5,
  :y 10.0,
  :drawable
  {:color
   [0.9098039215686274 0.9098039215686274 0.9098039215686274 1.0],
   :drawables
   ({:style :membrane.ui/style-fill,
     :drawables [{:width 437.5, :height 342.0}]})}}
 {:x 42.5,
  :y 0.0,
  :drawable
  [{:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([29.88636363636363 10] [29.88636363636363 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([79.60227272727272 10] [79.60227272727272 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([129.3181818181818 10] [129.3181818181818 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([179.03409090909088 10] [179.03409090909088 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables [{:points ([228.75 10] [228.75 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([278.4659090909091 10] [278.4659090909091 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([328.1818181818182 10] [328.1818181818182 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([377.89772727272725 10] [377.89772727272725 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([427.6136363636363 10] [427.6136363636363 352.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([10 336.45454545454544] [447.5 336.45454545454544])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([10 284.6363636363636] [447.5 284.6363636363636])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([10 232.8181818181818] [447.5 232.8181818181818])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([10 181.00000000000003] [447.5 181.00000000000003])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([10 129.18181818181822] [447.5 129.18181818181822])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([10 77.36363636363637] [447.5 77.36363636363637])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([10 25.54545454545456] [447.5 25.54545454545456])}]}]})}
   {:x 26.88636363636363,
    :y 333.45454545454544,
    :drawable
    [{:color
      [0.8941176470588236
       0.10196078431372549
       0.10980392156862745
       0.75],
      :drawables
      ({:style :membrane.ui/style-fill,
        :drawables [{:width 6.0, :height 6.0, :border-radius 3.0}]})}
     nil],
    :row-idx 0}
   {:x 126.31818181818181,
    :y 126.18181818181822,
    :drawable
    [{:color
      [0.8941176470588236
       0.10196078431372549
       0.10980392156862745
       0.75],
      :drawables
      ({:style :membrane.ui/style-fill,
        :drawables [{:width 6.0, :height 6.0, :border-radius 3.0}]})}
     nil],
    :row-idx 1}
   {:x 225.75,
    :y 229.8181818181818,
    :drawable
    [{:color
      [0.21568627450980393
       0.49411764705882355
       0.7215686274509804
       0.75],
      :drawables
      ({:style :membrane.ui/style-fill,
        :drawables [{:width 6.0, :height 6.0, :border-radius 3.0}]})}
     nil],
    :row-idx 2}
   {:x 325.1818181818182,
    :y 22.54545454545456,
    :drawable
    [{:color
      [0.21568627450980393
       0.49411764705882355
       0.7215686274509804
       0.75],
      :drawables
      ({:style :membrane.ui/style-fill,
        :drawables [{:width 6.0, :height 6.0, :border-radius 3.0}]})}
     nil],
    :row-idx 3}
   {:x 424.6136363636363,
    :y 126.18181818181822,
    :drawable
    [{:color
      [0.21568627450980393
       0.49411764705882355
       0.7215686274509804
       0.75],
      :drawables
      ({:style :membrane.ui/style-fill,
        :drawables [{:width 6.0, :height 6.0, :border-radius 3.0}]})}
     nil],
    :row-idx 4}
   {:x 29.88636363636363,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "1.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 79.60227272727272,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "1.5",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 129.3181818181818,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "2.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 179.03409090909088,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "2.5",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 228.75,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "3.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 278.4659090909091,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "3.5",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 328.1818181818182,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "4.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 377.89772727272725,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "4.5",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 427.6136363636363,
    :y 364.0,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "5.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "middle"})}}
   {:x 7.0,
    :y 330.95454545454544,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "2.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "end"})}}
   {:x 7.0,
    :y 279.1363636363636,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "2.5",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "end"})}}
   {:x 7.0,
    :y 227.3181818181818,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "3.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "end"})}}
   {:x 7.0,
    :y 175.50000000000003,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "3.5",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "end"})}}
   {:x 7.0,
    :y 123.68181818181822,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "4.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "end"})}}
   {:x 7.0,
    :y 71.86363636363637,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "4.5",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "end"})}}
   {:x 7.0,
    :y 20.04545454545456,
    :drawable
    {:color [0.4 0.4 0.4 1.0],
     :drawables
     ({:text "5.0",
       :font
       {:name nil, :size 11, :weight nil, :width nil, :slant nil},
       :text-anchor "end"})}}]}]

Plot

membrane->plot converts the membrane tree into SVG hiccup.

(def trace-plot
  (pj/membrane->plot trace-membrane :svg
                     {:total-width (:total-width trace-plan)
                      :total-height (:total-height trace-plan)}))
(kind/pprint trace-plot)
[:svg
 {:xmlns "http://www.w3.org/2000/svg",
  :width 600.0,
  :height 400.0,
  :viewBox "0 0 600.0 400.0",
  :role "img",
  :font-family
  "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"}
 [:g
  [:g
   {:transform "translate(12.00,181.00)"}
   [:g
    {:transform "rotate(-90.00)"}
    [:g
     [:text
      {:fill "rgb(51,51,51)",
       :fill-opacity 1.0,
       :font-size 13,
       :dominant-baseline "hanging",
       :text-anchor "middle"}
      "y"]]]]
  [:g
   {:transform "translate(271.25,382.00)"}
   [:g
    [:text
     {:fill "rgb(51,51,51)",
      :fill-opacity 1.0,
      :font-size 13,
      :dominant-baseline "hanging",
      :text-anchor "middle"}
     "x"]]]
  [:g
   {:transform "translate(510.00,2.00)"}
   [:g
    [:text
     {:fill "rgb(51,51,51)",
      :fill-opacity 1.0,
      :font-size 11,
      :dominant-baseline "hanging"}
     "g"]]]
  [:g
   {:transform "translate(510.00,20.00)"}
   [:g
    [:g
     {:transform "translate(0.00,0.00)"}
     [:g
      [:g
       [:rect
        {:y 0,
         :rx 4.0,
         :stroke "none",
         :fill "rgb(228,26,28)",
         :width 8,
         :x 0,
         :ry 4.0,
         :fill-opacity 1.0,
         :height 8}]]]]
    [:g
     {:transform "translate(12.00,0.00)"}
     [:g
      [:text
       {:fill "rgb(51,51,51)",
        :fill-opacity 1.0,
        :font-size 10,
        :dominant-baseline "hanging"}
       "a"]]]]]
  [:g
   {:transform "translate(510.00,36.00)"}
   [:g
    [:g
     {:transform "translate(0.00,0.00)"}
     [:g
      [:g
       [:rect
        {:y 0,
         :rx 4.0,
         :stroke "none",
         :fill "rgb(55,126,184)",
         :width 8,
         :x 0,
         :ry 4.0,
         :fill-opacity 1.0,
         :height 8}]]]]
    [:g
     {:transform "translate(12.00,0.00)"}
     [:g
      [:text
       {:fill "rgb(51,51,51)",
        :fill-opacity 1.0,
        :font-size 10,
        :dominant-baseline "hanging"}
       "b"]]]]]
  [:g
   {:transform "translate(52.50,10.00)"}
   [:g
    [:g
     [:rect
      {:fill "rgb(232,232,232)",
       :fill-opacity 1.0,
       :stroke "none",
       :x 0,
       :y 0,
       :width 437.5,
       :height 342.0}]]]]
  [:g
   {:transform "translate(42.50,0.00)"}
   [:g
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "29.89,10.00 29.89,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "79.60,10.00 79.60,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "129.32,10.00 129.32,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "179.03,10.00 179.03,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "228.75,10.00 228.75,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "278.47,10.00 278.47,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "328.18,10.00 328.18,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "377.90,10.00 377.90,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "427.61,10.00 427.61,352.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "10.00,336.45 447.50,336.45"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "10.00,284.64 447.50,284.64"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "10.00,232.82 447.50,232.82"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "10.00,181.00 447.50,181.00"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "10.00,129.18 447.50,129.18"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "10.00,77.36 447.50,77.36"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "10.00,25.55 447.50,25.55"}]]]]
    [:g
     {:transform "translate(26.89,333.45)", :data-row-idx 0}
     [:g
      [:g
       [:rect
        {:y 0,
         :rx 3.0,
         :stroke "none",
         :fill "rgb(228,26,28)",
         :width 6.0,
         :x 0,
         :ry 3.0,
         :fill-opacity 0.75,
         :height 6.0}]]]]
    [:g
     {:transform "translate(126.32,126.18)", :data-row-idx 1}
     [:g
      [:g
       [:rect
        {:y 0,
         :rx 3.0,
         :stroke "none",
         :fill "rgb(228,26,28)",
         :width 6.0,
         :x 0,
         :ry 3.0,
         :fill-opacity 0.75,
         :height 6.0}]]]]
    [:g
     {:transform "translate(225.75,229.82)", :data-row-idx 2}
     [:g
      [:g
       [:rect
        {:y 0,
         :rx 3.0,
         :stroke "none",
         :fill "rgb(55,126,184)",
         :width 6.0,
         :x 0,
         :ry 3.0,
         :fill-opacity 0.75,
         :height 6.0}]]]]
    [:g
     {:transform "translate(325.18,22.55)", :data-row-idx 3}
     [:g
      [:g
       [:rect
        {:y 0,
         :rx 3.0,
         :stroke "none",
         :fill "rgb(55,126,184)",
         :width 6.0,
         :x 0,
         :ry 3.0,
         :fill-opacity 0.75,
         :height 6.0}]]]]
    [:g
     {:transform "translate(424.61,126.18)", :data-row-idx 4}
     [:g
      [:g
       [:rect
        {:y 0,
         :rx 3.0,
         :stroke "none",
         :fill "rgb(55,126,184)",
         :width 6.0,
         :x 0,
         :ry 3.0,
         :fill-opacity 0.75,
         :height 6.0}]]]]
    [:g
     {:transform "translate(29.89,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "1.0"]]]
    [:g
     {:transform "translate(79.60,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "1.5"]]]
    [:g
     {:transform "translate(129.32,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "2.0"]]]
    [:g
     {:transform "translate(179.03,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "2.5"]]]
    [:g
     {:transform "translate(228.75,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "3.0"]]]
    [:g
     {:transform "translate(278.47,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "3.5"]]]
    [:g
     {:transform "translate(328.18,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "4.0"]]]
    [:g
     {:transform "translate(377.90,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "4.5"]]]
    [:g
     {:transform "translate(427.61,364.00)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "middle"}
       "5.0"]]]
    [:g
     {:transform "translate(7.00,330.95)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "end"}
       "2.0"]]]
    [:g
     {:transform "translate(7.00,279.14)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "end"}
       "2.5"]]]
    [:g
     {:transform "translate(7.00,227.32)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "end"}
       "3.0"]]]
    [:g
     {:transform "translate(7.00,175.50)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "end"}
       "3.5"]]]
    [:g
     {:transform "translate(7.00,123.68)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "end"}
       "4.0"]]]
    [:g
     {:transform "translate(7.00,71.86)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "end"}
       "4.5"]]]
    [:g
     {:transform "translate(7.00,20.05)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging",
        :text-anchor "end"}
       "5.0"]]]]]]]

And this is what it looks like when rendered:

(kind/hiccup trace-plot)
yxgab1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

Shortcut: Pose to Plan

In practice, pj/plan does the pose-to-plan conversion in one step – computing the draft and running draft->plan internally.

(def shortcut-plan (pj/plan trace-pose))
(ss/valid? shortcut-plan)
true

Pipeline Summary

Stage Type Coordinates
Pose Plain map (leaf or composite) N/A (declarative)
Draft Vector of maps N/A (declarative)
Plan Clojure maps + dtype buffers Data space
Membrane Record tree Drawing units
Plot Hiccup vectors Drawing units

The Plan Boundary

The plan is the boundary between description and rendering. The pose and draft stages assemble the description. The plan resolves it into computed geometry, domains, ticks, and legend – still as plain data, before any layout. The membrane and plot stages then produce the rendered output.

graph LR A["Pose + draft"] -->|plan| P["Plan"] P --> R["membrane + plot"] style A fill:#e8f5e9 style P fill:#fff3e0 style R fill:#e3f2fd

The plan is plain inspectable data – maps, numbers, strings, keywords, and dtype-next buffers for numeric arrays. It validates against a Malli schema.

This separation enables:

  • Inspecting the plan without rendering

  • Validating plot structure with Malli

  • Adding alternate backends that consume plans (SVG and raster are implemented today)

Multi-Layer Example

A pose can hold multiple layers that share one mapping. Here, scatter points and per-species regression lines share the same panel because both lay-point and lay-smooth target the same :petal-length/:petal-width mapping.

(def multi-pose
  (-> (rdatasets/datasets-iris)
      (pj/pose :petal-length :petal-width {:color :species})
      pj/lay-point
      (pj/lay-smooth {:stat :linear-model})))

The pose has one leaf with two pose-level layers:

(count (:layers multi-pose))
2
(mapv :layer-type (:layers multi-pose))
[:point :smooth]

The draft produces two maps – one per layer – both sharing the same columns:

(def multi-draft (pj/draft multi-pose))
(count multi-draft)
2
(mapv :mark multi-draft)
[:point :line]

Building a plan with a title and checking the layers:

(def multi-plan
  (pj/plan multi-pose {:title "Iris Petals with Regression"}))
(mapv (fn [layer]
        {:mark (:mark layer)
         :n-groups (count (:groups layer))})
      (:layers (first (:panels multi-plan))))
[{:mark :point, :n-groups 3} {:mark :line, :n-groups 3}]

Title and legend are top-level plan keys:

multi-plan
{:panels
 [{:coord :cartesian,
   :y-domain [-0.01999999999999999 2.62],
   :x-scale {:type :linear},
   :x-domain [0.705 7.195],
   :x-ticks
   {:values [1.0 2.0 3.0 4.0 5.0 6.0 7.0],
    :labels ["1" "2" "3" "4" "5" "6" "7"],
    :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<float64>[50]
: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>[50]
: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...],
       :label "setosa",
       :row-indices #tech.v3.dataset.column<int64>[50]
:__row-idx
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19...]}
      {:color
       [0.21568627450980393
        0.49411764705882355
        0.7215686274509804
        1.0],
       :xs #tech.v3.dataset.column<float64>[50]
:petal-length
[4.700, 4.500, 4.900, 4.000, 4.600, 4.500, 4.700, 3.300, 4.600, 3.900, 3.500, 4.200, 4.000, 4.700, 3.600, 4.400, 4.500, 4.100, 4.500, 3.900...],
       :ys #tech.v3.dataset.column<float64>[50]
:petal-width
[1.400, 1.500, 1.500, 1.300, 1.500, 1.300, 1.600, 1.000, 1.300, 1.400, 1.000, 1.500, 1.000, 1.400, 1.300, 1.400, 1.500, 1.000, 1.500, 1.100...],
       :label "versicolor",
       :row-indices #tech.v3.dataset.column<int64>[50]
:__row-idx
[50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69...]}
      {:color
       [0.30196078431372547 0.6862745098039216 0.2901960784313726 1.0],
       :xs #tech.v3.dataset.column<float64>[50]
:petal-length
[6.000, 5.100, 5.900, 5.600, 5.800, 6.600, 4.500, 6.300, 5.800, 6.100, 5.100, 5.300, 5.500, 5.000, 5.100, 5.300, 5.500, 6.700, 6.900, 5.000...],
       :ys #tech.v3.dataset.column<float64>[50]
:petal-width
[2.500, 1.900, 2.100, 1.800, 2.200, 2.100, 1.700, 1.800, 1.800, 2.500, 2.000, 1.900, 2.100, 2.000, 2.400, 2.300, 1.800, 2.200, 2.300, 1.500...],
       :label "virginica",
       :row-indices #tech.v3.dataset.column<int64>[50]
:__row-idx
[100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119...]}],
     :y-domain [0.1 2.5],
     :x-domain [1.0 6.9]}
    {:mark :line,
     :style {:stroke-width 2.5, :opacity 1.0},
     :groups
     [{:color
       [0.8941176470588236
        0.10196078431372549
        0.10980392156862745
        1.0],
       :label "setosa",
       :x1 1.0,
       :y1 0.15302476654486402,
       :x2 1.9,
       :y2 0.33414535119772626}
      {:color
       [0.21568627450980393
        0.49411764705882355
        0.7215686274509804
        1.0],
       :label "versicolor",
       :x1 3.0,
       :y1 0.9088724584103528,
       :x2 5.1,
       :y2 1.6040850277264331}
      {:color
       [0.30196078431372547 0.6862745098039216 0.2901960784313726 1.0],
       :label "virginica",
       :x1 4.5,
       :y1 1.8573676029159527,
       :x2 6.9,
       :y2 2.2420802958833654}],
     :y-domain [0.1 2.5],
     :x-domain [1.0 6.9]}],
   :y-scale {:type :linear},
   :y-ticks
   {:values [-0.0 0.5 1.0 1.5 2.0 2.5],
    :labels ["0.0" "0.5" "1.0" "1.5" "2.0" "2.5"],
    :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 102,
  :caption-pad 0,
  :y-label-pad 42.5,
  :legend-h 0.0,
  :title-pad 33,
  :strip-h 0,
  :x-label-pad 38,
  :strip-w 0.0},
 :grid {:rows 1, :cols 1},
 :legend
 {:title :species,
  :entries
  [{:label "setosa",
    :color
    [0.8941176470588236 0.10196078431372549 0.10980392156862745 1.0]}
   {:label "versicolor",
    :color
    [0.21568627450980393 0.49411764705882355 0.7215686274509804 1.0]}
   {:label "virginica",
    :color
    [0.30196078431372547 0.6862745098039216 0.2901960784313726 1.0]}]},
 :panel-height 329.0,
 :title "Iris Petals with Regression",
 :y-label "petal width",
 :alpha-legend nil,
 :x-label "petal length",
 :subtitle nil,
 :panel-width 455.5,
 :size-legend nil,
 :total-height 400.0,
 :tooltip nil,
 :margin 10}

And the rendered result:

(-> (rdatasets/datasets-iris)
    (pj/pose :petal-length :petal-width {:color :species})
    pj/lay-point
    (pj/lay-smooth {:stat :linear-model})
    (pj/options {:title "Iris Petals with Regression"}))
Iris Petals with Regressionpetal widthpetal lengthspeciessetosaversicolorvirginica12345670.00.51.01.52.02.5

Namespace Structure

graph TD API["api.clj"] --> POSE["impl/pose.clj"] API --> RES["impl/resolve.clj"] API --> PL["impl/plan.clj"] API --> COMP["impl/compositor.clj"] POSE --> RES COMP --> POSE COMP --> PL PL --> RES PL --> STAT["impl/stat.clj"] PL --> SCALE["impl/scale.clj"] PL --> DEFAULTS["impl/defaults.clj"] PL --> PS["impl/plan_schema.clj"] API --> RENDER["impl/render.clj"] RENDER --> SVG["render/svg.clj"] SVG --> MEMBRANE["render/membrane.clj"] MEMBRANE --> PANEL["render/panel.clj"] PANEL --> MARK["render/mark.clj"] PANEL --> SCALE PANEL --> COORD["impl/coord.clj"] style API fill:#c8e6c9 style FR fill:#d1c4e9 style COMP fill:#d1c4e9 style PL fill:#d1c4e9 style SVG fill:#f8bbd0 style MEMBRANE fill:#f8bbd0

impl/pose.clj holds the pose substrate: resolve-tree (merges mappings/data/options down from root to every leaf), leaf->draft (flattens a leaf into draft maps with optional facet expansion), and the multi-pair / grid composite utilities. impl/compositor.clj handles composite rendering – each leaf becomes a sub-plot, tiled via layout. impl/plan.clj holds draft->plan (domains, ticks, legends, layout). impl/resolve.clj holds resolve-draft-layer (single draft layer resolution, column type inference, grouping).

The impl/ directory is pure data – no membrane dependency. The render/ directory uses membrane for layout and SVG conversion.

Dependencies

Plotje builds on several excellent Clojure libraries:

What’s Next

  • Exploring Plans – a hands-on tour of the plan stage, building intuition for the data shape that the pipeline produces
  • Extensibility – add custom marks, stats, scales, coordinate systems, and output formats by extending the multimethods at each pipeline stage
source: notebooks/plotje_book/architecture.clj