30 Extensibility
Plotje 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 plotje-book.extensibility
(:require
;; Rdatasets -- standard datasets
[scicloj.metamorph.ml.rdatasets :as rdatasets]
;; Kindly -- notebook rendering protocol
[scicloj.kindly.v4.kind :as kind]
;; Plotje -- composable plotting
[scicloj.plotje.api :as pj]
;; Layer-type registry -- for inspecting layer-type data
[scicloj.plotje.layer-type :as layer-type]
;; Implementation namespaces -- for extension points
[scicloj.plotje.impl.stat :as stat]
[scicloj.plotje.impl.extract :as extract]
[scicloj.plotje.render.mark :as mark]
[scicloj.plotje.render.svg :as svg]
[scicloj.plotje.impl.render :as render]
;; Membrane UI protocols -- read membrane width/height
[membrane.ui]))Overview
The pipeline from pose to plot has five stages, with the transitions between them governed by multimethods or fixed- function steps. The dotted plan->plot edge is an alternative direct path – skipping the membrane stage – that backends can register against if they build their figure directly from plan data.
| 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 a stat result into a plan layer descriptor |
layer->membrane |
render/mark.clj |
:mark key |
Render a plan layer as membrane drawables |
plan->plot |
impl/render.clj |
format keyword | Convert a plan directly into a figure (the direct path) |
membrane->plot |
impl/render.clj |
format keyword | Convert a membrane tree into a figure (the membrane path) |
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 layer type uses a stat to prepare data for rendering.
Dispatch function: (fn [draft-layer] (or (:stat draft-layer) :identity))
(kind/table
{:column-names ["Dispatch value" "What it does"]
:row-maps
(->> (methods stat/compute-stat)
keys
(filter keyword?)
(remove #{:default})
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"What it does" (pj/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 |
|
Density — 1D kernel density estimation (KDE) |
|
Density 2D — 2D Gaussian kernel density estimation (KDE) |
|
Pass-through — no transform |
|
Linear model — OLS regression line + optional confidence band |
|
LOESS (local regression) smoothing |
|
Mean ± standard error per category |
|
KDE per category (density profile) |
The stat is part of the layer type returned by layer-type/lookup. For example, (layer-type/lookup :histogram) returns a layer type with :stat :bin:
(layer-type/lookup :histogram){:mark :bar,
:stat :bin,
:x-only true,
:accepts [:normalize :bins :binwidth],
:doc "Histogram — bins numerical data into bars."}(layer-type/lookup :bar) returns a layer type with :stat :count:
(layer-type/lookup :bar){:mark :rect,
:stat :count,
:x-only true,
:accepts [],
:doc "Bar — counts categorical values."}(layer-type/lookup :point) returns a layer type with :stat :identity:
(layer-type/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.
Pseudocode:
(defmethod stat/compute-stat :loess [draft-layer]
;; Compute LOESS smoothing from draft-layer'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 requires – 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:
:identityreturns{:points [...] :x-domain [...] :y-domain [...]}:binreturns{:bins [...] :max-count ... :x-domain [...] :y-domain [...]}:boxplotreturns{: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.
Dispatch function: (fn [draft-layer stat all-colors cfg] (:mark draft-layer))
(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" (pj/mark-doc k)})))})| Dispatch value | Output |
|---|---|
|
Filled region under a curve |
|
Vertical rectangles (binned) |
|
Box-and-whisker |
|
Iso-value polylines |
|
Vertical error bar |
|
Horizontal bars from x to x-end at categorical y |
|
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 |
A plan layer looks like this. Starting from a familiar iris scatter:
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species}))The plan layer extracted from that pose:
(let [s (-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
pj/plan)
layer (first (:layers (first (:panels s))))]
layer){:mark :point,
:style {:opacity 0.75, :radius 3.0},
:size-scale nil,
:alpha-scale nil,
:groups
[{:color
[0.8941176470588236 0.10196078431372549 0.10980392156862745 1.0],
:xs #tech.v3.dataset.column<float64>[50]
: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).
Dispatch function: (fn [layer ctx] (:mark layer))
(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" (pj/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 |
|
Filled rectangles spanning x to x-end at categorical y |
|
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 |
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.
Pseudocode:
;; 1. Extract geometry from stat result
(defmethod extract/extract-layer :area [draft-layer 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 layer type and create a pose-compatible layer function
After defining compute-stat and extract-layer for your custom mark, register a layer type and create a convenience function that works with the pose API:
;; Register the layer type
(layer-type/register! :waterfall
{:mark :waterfall :stat :waterfall
:doc "Waterfall -- running total with increase/decrease bars."})
;; Users can then call:
;; (pj/lay data (layer-type/lookup :waterfall))
;; Or create a convenience function using lay:
(defn lay-waterfall
([pose] (pj/lay pose (layer-type/lookup :waterfall)))
([data x y] (-> data (pj/pose x y) (pj/lay (layer-type/lookup :waterfall))))
([data x y opts] (-> data (pj/pose x y) (pj/lay (merge (layer-type/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.
Annotation marks live on the panel’s :annotations, not :layers
The four annotation marks – :rule-h, :rule-v, :band-h, :band-v – are split out of the per-panel :layers list during planning and rendered from a separate :annotations slot on each panel of the resolved plan. Extension authors building tooling that walks a plan should expect to find these on panel :annotations, not on panel :layers. The split is driven by scicloj.plotje.impl.resolve/annotation-marks, the canonical set of annotation mark keywords; if you add a custom annotation-style mark that should follow the same lifecycle, register it there.
plan->plot
Orchestrates the full path from plan to plot. The :svg implementation goes through the membrane path: plan, then membrane, then plot. Other renderers can skip membrane and go directly from plan to their target format.
Dispatch function: (fn [plan format opts] format)
| Dispatch value | Path |
|---|---|
:svg |
plan, then membrane, then membrane->plot :svg |
:bufimg |
plan, then membrane, then membrane->plot :bufimg (raster image) |
Note that pj/save’s :png file format is not a separate dispatch value – it routes through the :bufimg path internally and encodes the resulting BufferedImage as PNG bytes on disk. The save-side keyword names the file format; the dispatch-side keyword names the JVM render target.
Using plan->plot directly:
(def my-plan
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
pj/plan))(first (pj/plan->plot my-plan :svg {})):svgThe same plan can be rendered to different formats:
(def my-figure (pj/plan->plot 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->plot defmethod.
Pseudocode:
(ns mylib.render.plotly
(:require [scicloj.plotje.impl.render :as render]))
(defmethod render/plan->plot :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])
(pj/plot pose {:format :plotly})membrane->plot
Converts a PlotjeMembrane into a plot for a given format. This is the extensibility point for membrane-based output formats – formats that consume the same drawable tree but walk it differently. The Membranes chapter walks the PlotjeMembrane record itself and the Membrane UI protocols it implements.
Dispatch function: (fn [membrane-tree format opts] format)
| Dispatch value | Output |
|---|---|
:svg |
SVG hiccup wrapped in kind/hiccup |
:bufimg |
Java BufferedImage wrapped in kind/buffered-image (raster) |
pj/plan->membrane builds the membrane, pj/membrane->plot converts it. The record carries plan-derived dimensions as fields and the title as :plotje/title, so pj/membrane->plot reads them off the value directly:
(def my-membrane (pj/plan->membrane my-plan))(pj/membrane? my-membrane)true(membrane.ui/width my-membrane)600(first (pj/membrane->plot my-membrane :svg {})):svgThe shorter shortcut pj/membrane runs the full chain from a pose to a membrane in one call – the natural starting point for a custom backend that consumes membranes:
(def shortcut-membrane
(pj/membrane (-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width
{:color :species}))))(pj/membrane? shortcut-membrane)trueHow to extend: add a new membrane-based format
To add a format that reuses the membrane tree (e.g., Canvas, PDF), register a membrane->plot defmethod. The defmethod reads canvas dimensions via the Membrane UI protocols and the title via :plotje/title, so it operates without the plan:
Pseudocode:
(ns mylib.render.canvas
(:require [membrane.ui :as ui]
[scicloj.plotje.impl.render :as render]
[scicloj.plotje.render.membrane :as membrane]))
(defmethod render/membrane->plot :canvas [membrane-tree _ opts]
(let [w (ui/width membrane-tree)
h (ui/height membrane-tree)
title (:plotje/title membrane-tree)]
;; Walk the drawable tree (via membrane.ui/children), emit
;; canvas draw calls at the correct canvas size
(canvas-walk membrane-tree w h title)))
;; Optionally register plan->plot for users who want a one-call
;; plan-to-figure path; pj/plot, pj/membrane, and pj/save do not
;; need it -- they go through plan->membrane and membrane->plot
;; directly.
(defmethod render/plan->plot :canvas [plan _ opts]
(-> plan
(membrane/plan->membrane opts)
(render/membrane->plot :canvas opts)))make-scale
Builds a wadogo scale from a domain and pixel range.
(kind/table
{:column-names ["Dispatch value" "Scale type"]
:row-maps
(->> (methods scicloj.plotje.impl.scale/make-scale)
keys
(filter keyword?)
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"Scale type" (pj/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 dispatch to :categorical. Numerical domains default to :linear, overridden to :log by (pj/scale pose :x :log).
make-coord
Builds a coordinate function that maps data-space (x, y) to drawing units (px, py).
(kind/table
{:column-names ["Dispatch value" "Behavior"]
:row-maps
(->> (methods scicloj.plotje.impl.coord/make-coord)
keys
(filter keyword?)
(remove #{:default})
sort
(mapv (fn [k] {"Dispatch value" (kind/code (pr-str k))
"Behavior" (pj/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:
(-> (rdatasets/datasets-iris)
(pj/lay-bar :species)
(pj/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 [draft-layer]
{: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:
(pj/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 0x77bf08e8](pj/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 0x77bf08e8](count (remove #{:default} (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->plot |
| A new output format (membrane-based) | membrane->plot + plan->plot |
| A new scale type | make-scale |
| A new coordinate system | make-coord |
| A new position adjustment | apply-position |
Background
- Architecture – the five-stage pipeline and key libraries
What’s Next
- Waterfall Extension – a worked example that uses the extension points above to add a new chart type
- Edge Cases – how the library handles unusual inputs