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
Bin numerical values into ranges
:bin2d
2D grid binning (heatmap counts)
:boxplot
Five-number summary + outliers
:count
Count occurrences per category
:identity
Pass-through β€” no transform
:kde
KDE (kernel density estimation) β€” 1D
:kde2d
2D Gaussian KDE (kernel density estimation)
:lm
Linear model (lm) β€” OLS regression line + optional confidence band
:loess
LOESS (local regression) smoothing
:summary
Mean Β± standard error per category
:violin
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
:area
Filled region under a curve
:bar
Vertical rectangles (binned)
:boxplot
Box-and-whisker
:contour
Iso-value polylines
:errorbar
Vertical error bar
:label
Label with background box
:line
Connected path
:lollipop
Stem with dot
:point
Filled circle
:pointrange
Point with error bar
:rect
Positioned rectangles
:ridgeline
Stacked density curves
:rug
Axis-margin tick marks
:step
Horizontal-then-vertical path
:text
Data-driven label
:tile
Grid of colored cells
:violin
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
:area
Closed filled polygons with baseline
:bar
Filled polygons (histogram bars)
:boxplot
Box + whiskers + median line + outlier points
:contour
Stroked iso-density polylines
:errorbar
Vertical lines with caps
:label
Text label with filled background box
:line
Stroked polylines
:lollipop
Stems with dots at category positions
:point
Translated colored rounded-rectangles
:pointrange
Point at mean + vertical SE line
:rect
Filled polygons (categorical/value bars)
:ridgeline
Overlapping filled density curves
:rug
Short stroked tick marks at axis margins
:step
Stroked step polylines
:text
Translated text labels
:tile
Translated colored rectangles (heatmap cells)
:violin
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 {}))
:svg

The same plan can be rendered to different formats:

(def my-figure (sk/plan->figure my-plan :svg {}))
(vector? my-figure)
true

How 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)}))
:svg

How 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
:categorical
Band scale (one band per category)
:linear
Continuous linear mapping
:log
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
:cartesian
Standard x-right, y-up mapping
:fixed
Fixed aspect ratio (1 data unit = 1 data unit)
:flip
Swap x and y axes
:polar
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))
species05101520253035404550setosaversicolorvirginica

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))))
11

Summary

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
source: notebooks/napkinsketch_book/extensibility.clj