22  Architecture

Napkinsketch transforms data into plots through a four-stage pipeline. This notebook documents the pipeline, the key data models, and how the codebase is organized.

(ns napkinsketch-book.architecture
  (:require
   ;; Shared datasets for these docs
   [napkinsketch-book.datasets :as data]
   ;; Kindly β€” notebook rendering protocol
   [scicloj.kindly.v4.kind :as kind]
   ;; Napkinsketch β€” composable plotting
   [scicloj.napkinsketch.api :as sk]
   ;; Malli schema validation
   [scicloj.napkinsketch.impl.sketch-schema :as ss]))

Pipeline Overview

graph LR V["Views
(API)"] -->|resolve| S["Plan
(data-space)"] S -->|scales + coords| M["Membrane
(pixel-space)"] M -->|tree walk| F["Figure
(output)"] style V fill:#e8f5e9 style S fill:#fff3e0 style M fill:#e3f2fd style F fill:#fce4ec
  • Views β€” user-facing compositional API: view, lay-point, lay-histogram, etc.

  • Plan β€” fully resolved plan. Data-space geometry, domains, tick info, legend. Plain Clojure maps. No rendering primitives.

  • Membrane β€” a value of the Membrane library: positioned drawing primitives in pixel space (Translate, WithColor, Path, Label, etc.).

  • Figure β€” final output. A tree walk converts membrane records to SVG hiccup, which Clay/Kindly renders in notebooks.

Pipeline Trace

Let’s trace a small example through all four 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]})

Views

The user composes views β€” a vector of plain maps describing what data to plot and how. No computation has happened yet.

(def trace-views
  (-> trace-data
      (sk/lay-point :x :y {:color :g})))
(kind/pprint trace-views)
{:views [{:data _unnamed [5 3]:

| :x | :y | :g |
|---:|---:|----|
|  1 |  2 | :a |
|  2 |  4 | :a |
|  3 |  3 | :b |
|  4 |  5 | :b |
|  5 |  4 | :b |
,
          :x :x,
          :y :y,
          :mark :point,
          :stat :identity,
          :accepts [:size :shape :jitter :text :nudge-x :nudge-y],
          :doc "Scatter β€” individual data points.",
          :color :g,
          :__base {:data _unnamed [5 3]:

| :x | :y | :g |
|---:|---:|----|
|  1 |  2 | :a |
|  2 |  4 | :a |
|  3 |  3 | :b |
|  4 |  5 | :b |
|  5 |  4 | :b |
, :x :x, :y :y}}],
 :opts {},
 :kindly/f #'scicloj.napkinsketch.api/render-sketch}

Plan

sk/plan resolves the views 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 β€” x=1 means the original data value 1, not a pixel position.

(def trace-plan (sk/plan trace-views))
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},
     :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 722.5,
 :legend-position :right,
 :layout-type :single,
 :layout
 {:subtitle-pad 0,
  :legend-w 100,
  :caption-pad 0,
  :y-label-pad 22.5,
  :legend-h 0,
  :title-pad 0,
  :strip-h 0,
  :x-label-pad 18,
  :strip-w 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 400.0,
 :title nil,
 :y-label "y",
 :alpha-legend nil,
 :x-label "x",
 :subtitle nil,
 :panel-width 600.0,
 :size-legend nil,
 :total-height 418.0,
 :margin 30}

The plan validates against a Malli schema:

(ss/valid? trace-plan)
true

Numeric arrays (:xs, :ys, etc.) are dtype-next buffers β€” efficient primitive-backed arrays that work with nth, count, and all standard sequence operations.

Membrane

sk/plan->membrane converts the plan into a tree of membrane drawing primitives positioned in pixel space. This is the format-agnostic intermediate representation β€” Translate, WithColor, WithStyle, RoundedRectangle, Label, Path, etc.

(def trace-membrane (sk/plan->membrane trace-plan))
trace-membrane
[{:x 12,
  :y 200.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 322.5,
  :y 402.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 632.5,
  :y 8,
  :drawable
  {:color [0.2 0.2 0.2 1.0],
   :drawables
   ({:text "g",
     :font
     {:name nil, :size 9, :weight nil, :width nil, :slant nil}})}}
 {:x 632.5,
  :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 632.5,
  :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 22.5,
  :y 0.0,
  :drawable
  [{:color
    [0.9098039215686274 0.9098039215686274 0.9098039215686274 1.0],
    :drawables
    ({:style :membrane.ui/style-fill,
      :drawables
      [{:x 30, :y 30, :drawable {:width 540.0, :height 340.0}}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([54.54545454545454 30] [54.54545454545454 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([115.90909090909089 30] [115.90909090909089 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([177.27272727272725 30] [177.27272727272725 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([238.6363636363636 30] [238.6363636363636 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables [{:points ([300.0 30] [300.0 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([361.3636363636364 30] [361.3636363636364 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([422.72727272727275 30] [422.72727272727275 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([484.09090909090907 30] [484.09090909090907 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([545.4545454545454 30] [545.4545454545454 370.0])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([30 354.54545454545456] [570.0 354.54545454545456])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([30 303.03030303030306] [570.0 303.03030303030306])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([30 251.51515151515153] [570.0 251.51515151515153])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([30 200.00000000000003] [570.0 200.00000000000003])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([30 148.48484848484853] [570.0 148.48484848484853])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points ([30 96.969696969697] [570.0 96.969696969697])}]}]})}
   {:color
    [0.9607843137254902 0.9607843137254902 0.9607843137254902 1.0],
    :drawables
    ({:stroke-width 0.6,
      :drawables
      [{:style :membrane.ui/style-stroke,
        :drawables
        [{:points
          ([30 45.454545454545496] [570.0 45.454545454545496])}]}]})}
   {:x 51.54545454545454,
    :y 351.54545454545456,
    :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}]})},
    :row-idx 0}
   {:x 174.27272727272725,
    :y 145.48484848484853,
    :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}]})},
    :row-idx 1}
   {:x 297.0,
    :y 248.51515151515153,
    :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}]})},
    :row-idx 2}
   {:x 419.72727272727275,
    :y 42.454545454545496,
    :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}]})},
    :row-idx 3}
   {:x 542.4545454545454,
    :y 145.48484848484853,
    :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}]})},
    :row-idx 4}
   {:x 45.11688311688311,
    :y 388.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}})}}
   {:x 106.48051948051946,
    :y 388.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}})}}
   {:x 167.84415584415584,
    :y 388.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}})}}
   {:x 229.20779220779218,
    :y 388.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}})}}
   {:x 290.57142857142856,
    :y 388.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}})}}
   {:x 351.93506493506493,
    :y 388.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}})}}
   {:x 413.2987012987013,
    :y 388.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}})}}
   {:x 474.6623376623376,
    :y 388.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}})}}
   {:x 536.0259740259739,
    :y 388.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}})}}
   {:x 10.5,
    :y 349.04545454545456,
    :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}})}}
   {:x 10.5,
    :y 297.53030303030306,
    :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}})}}
   {:x 10.5,
    :y 246.01515151515153,
    :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}})}}
   {:x 10.5,
    :y 194.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}})}}
   {:x 10.5,
    :y 142.98484848484853,
    :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}})}}
   {:x 10.5,
    :y 91.469696969697,
    :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}})}}
   {:x 10.5,
    :y 39.954545454545496,
    :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}})}}]}]

Figure

sk/membrane->figure converts the membrane tree into a figure. The :svg format produces SVG hiccup. wrap-svg adds the root <svg> element.

(def trace-figure
  (sk/membrane->figure trace-membrane :svg
                       {:total-width (:total-width trace-plan)
                        :total-height (:total-height trace-plan)}))
(kind/pprint trace-figure)
[:svg
 {:xmlns "http://www.w3.org/2000/svg",
  :width 722.5,
  :height 418.0,
  :viewBox "0 0 722.5 418.0",
  :role "img",
  :font-family
  "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"}
 [:g
  [:g
   {:transform "translate(12.0,200.0)"}
   [:g
    {:transform "rotate(-90.0)"}
    [:g
     [:text
      {:fill "rgb(51,51,51)",
       :fill-opacity 1.0,
       :font-size 13,
       :dominant-baseline "hanging",
       :text-anchor "middle"}
      "y"]]]]
  [:g
   {:transform "translate(322.5,402.0)"}
   [:g
    [:text
     {:fill "rgb(51,51,51)",
      :fill-opacity 1.0,
      :font-size 13,
      :dominant-baseline "hanging",
      :text-anchor "middle"}
     "x"]]]
  [:g
   {:transform "translate(632.5,8.0)"}
   [:g
    [:text
     {:fill "rgb(51,51,51)",
      :fill-opacity 1.0,
      :font-size 9,
      :dominant-baseline "hanging"}
     "g"]]]
  [:g
   {:transform "translate(632.5,20.0)"}
   [:g
    [:g
     {:transform "translate(0.0,0.0)"}
     [: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.0,0.0)"}
     [:g
      [:text
       {:fill "rgb(51,51,51)",
        :fill-opacity 1.0,
        :font-size 10,
        :dominant-baseline "hanging"}
       ":a"]]]]]
  [:g
   {:transform "translate(632.5,36.0)"}
   [:g
    [:g
     {:transform "translate(0.0,0.0)"}
     [: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.0,0.0)"}
     [:g
      [:text
       {:fill "rgb(51,51,51)",
        :fill-opacity 1.0,
        :font-size 10,
        :dominant-baseline "hanging"}
       ":b"]]]]]
  [:g
   {:transform "translate(22.5,0.0)"}
   [:g
    [:g
     [:g
      [:g
       {:transform "translate(30.0,30.0)"}
       [:rect
        {:fill "rgb(232,232,232)",
         :fill-opacity 1.0,
         :stroke "none",
         :x 0,
         :y 0,
         :width 540.0,
         :height 340.0}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "54.54545454545454,30.0 54.54545454545454,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "115.90909090909089,30.0 115.90909090909089,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "177.27272727272725,30.0 177.27272727272725,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "238.6363636363636,30.0 238.6363636363636,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "300.0,30.0 300.0,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "361.3636363636364,30.0 361.3636363636364,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "422.72727272727275,30.0 422.72727272727275,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "484.09090909090907,30.0 484.09090909090907,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "545.4545454545454,30.0 545.4545454545454,370.0"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "30.0,354.54545454545456 570.0,354.54545454545456"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "30.0,303.03030303030306 570.0,303.03030303030306"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "30.0,251.51515151515153 570.0,251.51515151515153"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "30.0,200.00000000000003 570.0,200.00000000000003"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "30.0,148.48484848484853 570.0,148.48484848484853"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points "30.0,96.969696969697 570.0,96.969696969697"}]]]]
    [:g
     [:g
      [:g
       [:polyline
        {:fill "none",
         :stroke "rgb(245,245,245)",
         :stroke-opacity 1.0,
         :stroke-width 0.6,
         :points
         "30.0,45.454545454545496 570.0,45.454545454545496"}]]]]
    [:g
     {:transform "translate(51.54545454545454,351.54545454545456)",
      :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(174.27272727272725,145.48484848484853)",
      :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(297.0,248.51515151515153)",
      :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(419.72727272727275,42.454545454545496)",
      :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(542.4545454545454,145.48484848484853)",
      :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(45.11688311688311,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "1.0"]]]
    [:g
     {:transform "translate(106.48051948051946,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "1.5"]]]
    [:g
     {:transform "translate(167.84415584415584,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "2.0"]]]
    [:g
     {:transform "translate(229.20779220779218,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "2.5"]]]
    [:g
     {:transform "translate(290.57142857142856,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "3.0"]]]
    [:g
     {:transform "translate(351.93506493506493,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "3.5"]]]
    [:g
     {:transform "translate(413.2987012987013,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "4.0"]]]
    [:g
     {:transform "translate(474.6623376623376,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "4.5"]]]
    [:g
     {:transform "translate(536.0259740259739,388.0)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "5.0"]]]
    [:g
     {:transform "translate(10.5,349.04545454545456)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "2.0"]]]
    [:g
     {:transform "translate(10.5,297.53030303030306)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "2.5"]]]
    [:g
     {:transform "translate(10.5,246.01515151515153)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "3.0"]]]
    [:g
     {:transform "translate(10.5,194.50000000000003)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "3.5"]]]
    [:g
     {:transform "translate(10.5,142.98484848484853)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "4.0"]]]
    [:g
     {:transform "translate(10.5,91.469696969697)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "4.5"]]]
    [:g
     {:transform "translate(10.5,39.954545454545496)"}
     [:g
      [:text
       {:fill "rgb(102,102,102)",
        :fill-opacity 1.0,
        :font-size 11,
        :dominant-baseline "hanging"}
       "5.0"]]]]]]]

And this is what it looks like when rendered:

(kind/hiccup trace-figure)
yxg:a:b1.01.52.02.53.03.54.04.55.02.02.53.03.54.04.55.0

Pipeline Summary

Stage Type Coordinates
Views Clojure maps N/A (declarative)
Plan Clojure maps + dtype buffers Data space
Membrane Record tree Pixel space
Figure Hiccup vectors Pixel space

The Plan Boundary

The plan separates what to draw from how to draw it. It sits between the two concerns.

graph LR subgraph WHAT ["WHAT β€” data + semantics"] V["Views"] ST["Statistics"] D["Domains"] C["Colors"] end subgraph HOW ["HOW β€” pixels + rendering"] SC["Scales (wadogo)"] CO["Coord transforms"] MS["Membrane tree"] SV["SVG conversion"] end WHAT -->|plan| HOW style WHAT fill:#e8f5e9 style HOW fill:#e3f2fd

The plan is plain inspectable data β€” maps, numbers, strings, keywords, and dtype-next buffers for numeric arrays. No membrane types, no datasets, no scale objects. It validates against a Malli schema.

The membrane tree is Java objects β€” Translate, WithColor, RoundedRectangle, Label, etc. All positions are resolved to pixel coordinates. Not serializable.

This separation enables:

  • Inspecting the plan without rendering

  • Validating plot structure with Malli

  • Adding other backends (Canvas, Plotly, Vega-Lite) that consume plans

Multi-Layer Example

A plan can hold multiple layers. Here, scatter points and per-species regression lines share the same panel.

(def multi-views
  (-> data/iris
      (sk/view :petal_length :petal_width {:color :species})
      sk/lay-point
      sk/lay-lm))
(def multi-plan (sk/plan multi-views {:title "Iris Petals with Regression"}))

Two layers in the plan β€” point and line:

(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},
     :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},
     :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 722.5,
 :legend-position :right,
 :layout-type :single,
 :layout
 {:subtitle-pad 0,
  :legend-w 100,
  :caption-pad 0,
  :y-label-pad 22.5,
  :legend-h 0,
  :title-pad 18,
  :strip-h 0,
  :x-label-pad 18,
  :strip-w 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 400.0,
 :title "Iris Petals with Regression",
 :y-label "petal width",
 :alpha-legend nil,
 :x-label "petal length",
 :subtitle nil,
 :panel-width 600.0,
 :size-legend nil,
 :total-height 436.0,
 :margin 30}

And it renders:

(-> multi-views (sk/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"] --> VIEW["impl/view.clj"] API --> PLOT["impl/plot.clj"] API --> PLAN["impl/sketch.clj"] PLAN --> VIEW PLAN --> STAT["impl/stat.clj"] PLAN --> SCALE["impl/scale.clj"] PLAN --> DEFAULTS["impl/defaults.clj"] PLOT --> PLAN PLOT --> 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 PLAN fill:#ffe0b2 style PLOT fill:#bbdefb style SVG fill:#f8bbd0 style MEMBRANE fill:#f8bbd0

The impl/ directory is pure data β€” no membrane dependency. The render/ directory uses membrane for pixel-space layout and SVG conversion.

Dependencies

Napkinsketch builds on several excellent Clojure libraries:

What’s Next

  • Extensibility β€” add custom marks, stats, scales, and renderers via multimethods
  • Exploring Plans β€” inspect plan data structures at each stage
source: notebooks/napkinsketch_book/architecture.clj