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
(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)trueNumeric 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)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.
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"}))Namespace Structure
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:
Tablecloth & dtype-next β dataset manipulation and high-performance numeric arrays
Membrane β rendering and layout
Wadogo β scales
Clojure2d β color palettes and gradients
Fastmath β statistics
Malli β schema validation
Whatβs Next
- Extensibility β add custom marks, stats, scales, and renderers via multimethods
- Exploring Plans β inspect plan data structures at each stage