23 Extensibility
Napkinsketch is built on multimethods β open dispatch points that let you add new marks, statistics, scales, coordinate systems, and output formats without modifying the core library.
This notebook catalogs every multimethod, shows its dispatch mechanism, lists the built-in implementations, and demonstrates how to extend each one.
(ns napkinsketch-book.extensibility
(: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]
;; Method registry β for inspecting method data
[scicloj.napkinsketch.method :as method]
;; Implementation namespaces β for extension points
[scicloj.napkinsketch.impl.stat :as stat]
[scicloj.napkinsketch.impl.extract :as extract]
[scicloj.napkinsketch.impl.sketch :as sketch]
[scicloj.napkinsketch.render.mark :as mark]
[scicloj.napkinsketch.render.svg :as svg]
[scicloj.napkinsketch.impl.render :as render]))Overview
The pipeline from data to figure has several stages, each governed by a multimethod:
views β views->plan (compute-stat, extract-layer, ...)
β
plan
β
plan->figure (orchestrates full path)
β
βββββββββββββββββ΄ββββββββββββββββ
membrane path direct path
planβmembrane planβfigure
β
membraneβfigure
β
figure figure
| Multimethod | Namespace | Dispatches on | Purpose |
|---|---|---|---|
compute-stat |
impl/stat.clj |
:stat key |
Transform data (identity, bin, count, lm, loess, kde, boxplot) |
extract-layer |
impl/extract.clj |
:mark key |
Convert stat result β plan layer descriptor |
layer->membrane |
render/mark.clj |
:mark key |
Render plan layer β membrane drawables |
plan->figure |
impl/render.clj |
format keyword | Orchestrate plan β figure (full path) |
membrane->figure |
impl/render.clj |
format keyword | Convert membrane tree β figure |
make-scale |
impl/scale.clj |
domain type + spec | Build a wadogo scale |
make-coord |
impl/coord.clj |
coord-type keyword | Build a coordinate function |
apply-position |
impl/position.clj |
position keyword | Adjust group layout (dodge, stack, fill) |
compute-stat
Transforms raw data into a statistical summary. Each method uses a stat to prepare data for rendering.
(kind/table
{:column-names ["Dispatch value" "What it does"]
:row-maps
(->> (methods stat/compute-stat)
keys
(filter keyword?)
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"What it does" (sk/stat-doc k)})))})| Dispatch value | What it does |
|---|---|
|
Bin numerical values into ranges |
|
2D grid binning (heatmap counts) |
|
Five-number summary + outliers |
|
Count occurrences per category |
|
Pass-through β no transform |
|
KDE (kernel density estimation) β 1D |
|
2D Gaussian KDE (kernel density estimation) |
|
Linear model (lm) β OLS regression line + optional confidence band |
|
LOESS (local regression) smoothing |
|
Mean Β± standard error per category |
|
KDE per category (density profile) |
Dispatch function: (fn [view] (or (:stat view) :identity))
The stat is part of the method returned by the mark map. For example, (method/lookup :histogram) returns a method with :stat :bin:
(method/lookup :histogram){:mark :bar,
:stat :bin,
:x-only true,
:accepts [:normalize],
:doc "Histogram β bins numerical data into bars."}(method/lookup :bar) returns a method with :stat :count:
(method/lookup :bar){:mark :rect,
:stat :count,
:x-only true,
:accepts [],
:doc "Bar β counts categorical values."}(method/lookup :point) returns a method with :stat :identity:
(method/lookup :point){:mark :point,
:stat :identity,
:accepts [:size :shape :jitter :text :nudge-x :nudge-y],
:doc "Scatter β individual data points."}How to extend: add a new stat
To add a new statistical transform (e.g., :loess for local regression), define a new defmethod:
(defmethod stat/compute-stat :loess [view]
;; Compute LOESS smoothing from view's :data, :x, :y
;; Return {:points [...] :x-domain [...] :y-domain [...]}
...)The return value must always include :x-domain and :y-domain. The rest of the shape depends on what the paired extract-layer expects β the stat and extractor are a matched pair. For point-like marks, return :points (groups of :xs, :ys). For other marks, study a similar existing pair as a template:
:identityβ{:points [...] :x-domain [...] :y-domain [...]}:binβ{:bins [...] :max-count ... :x-domain [...] :y-domain [...]}:boxplotβ{:boxes [...] :categories [...] :x-domain [...] :y-domain [...]}
extract-layer
Converts a stat result into a plan layer descriptor β a plain map with data-space geometry and resolved colors.
(kind/table
{:column-names ["Dispatch value" "Output"]
:row-maps
(->> (methods extract/extract-layer)
keys
(filter keyword?)
(remove #{:default})
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"Output" (sk/mark-doc k)})))})| Dispatch value | Output |
|---|---|
|
Filled region under a curve |
|
Vertical rectangles (binned) |
|
Box-and-whisker |
|
Iso-value polylines |
|
Vertical error bar |
|
Label with background box |
|
Connected path |
|
Stem with dot |
|
Filled circle |
|
Point with error bar |
|
Positioned rectangles |
|
Stacked density curves |
|
Axis-margin tick marks |
|
Horizontal-then-vertical path |
|
Data-driven label |
|
Grid of colored cells |
|
Mirrored density shape |
Dispatch function: (fn [view stat all-colors cfg] (:mark view))
A plan layer looks like this:
(let [s (-> data/iris
(sk/lay-point :sepal_length :sepal_width {:color :species})
sk/plan)
layer (first (:layers (first (:panels s))))]
layer){: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]
:sepal_length
[5.100, 4.900, 4.700, 4.600, 5.000, 5.400, 4.600, 5.000, 4.400, 4.900, 5.400, 4.800, 4.800, 4.300, 5.800, 5.700, 5.400, 5.100, 5.700, 5.100...],
:ys #tech.v3.dataset.column<float64>[50]
:sepal_width
[3.500, 3.000, 3.200, 3.100, 3.600, 3.900, 3.400, 3.400, 2.900, 3.100, 3.700, 3.400, 3.000, 3.000, 4.000, 4.400, 3.900, 3.500, 3.800, 3.800...],
: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]
:sepal_length
[7.000, 6.400, 6.900, 5.500, 6.500, 5.700, 6.300, 4.900, 6.600, 5.200, 5.000, 5.900, 6.000, 6.100, 5.600, 6.700, 5.600, 5.800, 6.200, 5.600...],
:ys #tech.v3.dataset.column<float64>[50]
:sepal_width
[3.200, 3.200, 3.100, 2.300, 2.800, 2.800, 3.300, 2.400, 2.900, 2.700, 2.000, 3.000, 2.200, 2.900, 2.900, 3.100, 3.000, 2.700, 2.200, 2.500...],
: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]
:sepal_length
[6.300, 5.800, 7.100, 6.300, 6.500, 7.600, 4.900, 7.300, 6.700, 7.200, 6.500, 6.400, 6.800, 5.700, 5.800, 6.400, 6.500, 7.700, 7.700, 6.000...],
:ys #tech.v3.dataset.column<float64>[50]
:sepal_width
[3.300, 2.700, 3.000, 2.900, 3.000, 3.000, 2.500, 2.900, 2.500, 3.600, 3.200, 2.700, 3.000, 2.500, 2.800, 3.200, 3.000, 3.800, 2.600, 2.200...],
: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 [2.0 4.4],
:x-domain [4.3 7.9]}layer->membrane
Renders a plan layer descriptor into membrane drawable primitives. This is the βmembrane pathβ β used when the target format goes through membrane (e.g., SVG).
(kind/table
{:column-names ["Dispatch value" "Membrane output"]
:row-maps
(->> (methods mark/layer->membrane)
keys
(filter keyword?)
(remove #{:default})
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"Membrane output" (sk/membrane-mark-doc k)})))})| Dispatch value | Membrane output |
|---|---|
|
Closed filled polygons with baseline |
|
Filled polygons (histogram bars) |
|
Box + whiskers + median line + outlier points |
|
Stroked iso-density polylines |
|
Vertical lines with caps |
|
Text label with filled background box |
|
Stroked polylines |
|
Stems with dots at category positions |
|
Translated colored rounded-rectangles |
|
Point at mean + vertical SE line |
|
Filled polygons (categorical/value bars) |
|
Overlapping filled density curves |
|
Short stroked tick marks at axis margins |
|
Stroked step polylines |
|
Translated text labels |
|
Translated colored rectangles (heatmap cells) |
|
Mirrored filled density polygon |
Dispatch function: (fn [layer ctx] (:mark layer))
How to extend: add a new mark type
Adding a new mark (e.g., :area for area charts) requires methods on both extract-layer and layer->membrane:
;; 1. Extract geometry from stat result
(defmethod extract/extract-layer :area [view stat all-colors cfg]
{:mark :area
:style {:opacity 0.5}
:groups (vec (for [{:keys [color xs ys]} (:points stat)]
{:color (extract/resolve-color ...)
:xs xs :ys ys}))})
;; 2. Render to membrane drawables
(defmethod mark/layer->membrane :area [layer ctx]
;; Build filled polygon from xs/ys + baseline
...)How to extend: register a method and create a layer function
After defining compute-stat and extract-layer for your custom mark, register a method and create a convenience function:
;; Register the method
(method/register! :waterfall
{:mark :waterfall :stat :waterfall
:doc "Waterfall β running total with increase/decrease bars."})
;; Create a layer function (follows the same pattern as built-in ones)
(defn lay-waterfall
([views] (sk/lay views (method/lookup :waterfall)))
([data x y] (-> data (sk/view x y) (sk/lay (merge (method/lookup :waterfall)))))
([data x y opts] (-> data (sk/view x y) (sk/lay (merge (method/lookup :waterfall) opts)))))Users can then call (lay-waterfall data :category :amount).
Note: if your custom mark is not one of the built-in marks, you also need a layer->membrane defmethod for the SVG renderer. Without one, the library throws an error explaining which defmethod to add.
plan->figure
Orchestrates the full plan β figure path. The :svg implementation goes through the membrane path: plan β membrane β figure. Other renderers can skip membrane and go directly from plan to their target format.
| Dispatch value | Path |
|---|---|
:svg |
plan β membrane β membrane->figure :svg |
Dispatch function: (fn [plan format opts] format)
Using plan->figure directly:
(def my-plan
(-> data/iris
(sk/lay-point :sepal_length :sepal_width {:color :species})
sk/plan))(first (sk/plan->figure my-plan :svg {})):svgThe same plan can be rendered to different formats:
(def my-figure (sk/plan->figure my-plan :svg {}))(vector? my-figure)trueHow to extend: add a new direct format
To add a Plotly renderer that reads plan data directly (no membrane needed), register a plan->figure defmethod:
(ns mylib.render.plotly
(:require [scicloj.napkinsketch.impl.render :as render]))
(defmethod render/plan->figure :plotly [plan _ opts]
;; Read plan domains, layers, legend, layout
;; Build a Plotly.js spec directly β no membrane needed
{:data (mapcat plan-layer->plotly-traces
(:layers (first (:panels plan))))
:layout {:xaxis {:title (:x-label plan)}
:yaxis {:title (:y-label plan)}}})Then users opt in by requiring the namespace:
(require '[mylib.render.plotly])
(sk/plot views {:format :plotly})membrane->figure
Converts a membrane drawable tree into a figure for a given format. This is the extensibility point for membrane-based output formats β formats that share the same drawable tree but walk it differently.
| Dispatch value | Output |
|---|---|
:svg |
SVG hiccup wrapped in kind/hiccup |
Dispatch function: (fn [membrane-tree format opts] format)
sk/plan->membrane builds the tree, sk/membrane->figure converts it:
(def my-membrane (sk/plan->membrane my-plan))(vector? my-membrane)true(first (sk/membrane->figure my-membrane :svg
{:total-width (:total-width my-plan)
:total-height (:total-height my-plan)})):svgHow to extend: add a new membrane-based format
To add a format that reuses the membrane tree (e.g., Canvas, PDF), register a membrane->figure defmethod:
(ns mylib.render.canvas
(:require [scicloj.napkinsketch.impl.render :as render]
[scicloj.napkinsketch.render.membrane :as membrane]))
(defmethod render/membrane->figure :canvas [membrane-tree _ opts]
;; Walk the same drawable tree, emit canvas draw calls
(canvas-walk membrane-tree))
;; Also register plan->figure to orchestrate the full path:
(defmethod render/plan->figure :canvas [plan _ opts]
(let [mt (membrane/plan->membrane plan)]
(render/membrane->figure mt :canvas
{:total-width (:total-width plan)
:total-height (:total-height plan)})))make-scale
Builds a wadogo scale from a domain and pixel range.
(kind/table
{:column-names ["Dispatch value" "Scale type"]
:row-maps
(->> (methods scicloj.napkinsketch.impl.scale/make-scale)
keys
(filter keyword?)
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"Scale type" (sk/scale-doc k)})))})| Dispatch value | Scale type |
|---|---|
|
Band scale (one band per category) |
|
Continuous linear mapping |
|
Logarithmic mapping |
Dispatch: inferred from the domain type and scale spec. Categorical domains β :categorical. Numerical domains default to :linear, overridden to :log by (sk/scale views :x :log).
make-coord
Builds a coordinate function that maps data-space (x, y) to pixel-space (px, py).
(kind/table
{:column-names ["Dispatch value" "Behavior"]
:row-maps
(->> (methods scicloj.napkinsketch.impl.coord/make-coord)
keys
(filter keyword?)
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"Behavior" (sk/coord-doc k)})))})| Dispatch value | Behavior |
|---|---|
|
Standard x-right, y-up mapping |
|
Fixed aspect ratio (1 data unit = 1 data unit) |
|
Swap x and y axes |
|
Radial mapping: xβangle, yβradius |
All four use the same scales β :flip swaps which scale maps to which pixel axis, and :polar maps x to angle and y to radius.
A flipped bar chart uses :flip coordinates:
(-> data/iris
(sk/lay-bar :species)
(sk/coord :flip))Self-Documenting Extensions
Every generated dispatch table in this notebook is built by introspecting multimethod keys at render time. When you extend a multimethod, your new dispatch value automatically appears in the table. You can also register a [:key :doc] defmethod to provide a description.
Adding a documented extension
Register both the implementation and a [:key :doc] defmethod:
(defmethod stat/compute-stat :quantile [view]
{:points [] :x-domain [0 1] :y-domain [0 1]})(defmethod stat/compute-stat [:quantile :doc] [_]
"Quantile regression bands")The doc helper picks it up immediately:
(sk/stat-doc :quantile)"Quantile regression bands"Missing documentation degrades gracefully
If you skip the [:key :doc] defmethod, the table still renders β the description falls back to β(no description)β instead of throwing an error. Let us remove the doc defmethod and verify:
(remove-method stat/compute-stat [:quantile :doc])#multifn[compute-stat 0x4ff98410](sk/stat-doc :quantile)"(no description)"Cleanup
Remove the example extension so it does not affect other tests:
(remove-method stat/compute-stat :quantile)#multifn[compute-stat 0x4ff98410](count (filter keyword? (keys (methods stat/compute-stat))))11Summary
| To add⦠| Extend⦠|
|---|---|
| A new statistical transform | compute-stat |
| A new mark type | extract-layer + layer->membrane |
| A new output format (direct) | plan->figure |
| A new output format (membrane-based) | membrane->figure + plan->figure |
| A new scale type | make-scale |
| A new coordinate system | make-coord |
| A new position adjustment | apply-position |
Whatβs Next
- Architecture β the four-stage pipeline and key libraries
- Edge Cases β how the library handles unusual inputs