29 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]))Overview
The pipeline from data to plot has several stages, each governed by a multimethod. The pose API adds a composable front-end that resolves into the same pipeline:
pose (pj/pose, pj/lay-*, pj/options, ...)
|
pose->draft
v
draft
|
draft->plan (compute-stat, extract-layer, ...)
v
plan
|
plan->plot (orchestrates full path)
v
----------------------
membrane path direct path
plan->membrane plan->plot
|
membrane->plot
v
plot plot
| 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 | Orchestrate the full path from plan to plot |
membrane->plot |
impl/render.clj |
format keyword | Convert a membrane tree into a plot |
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 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:
: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:
(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) |
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 membrane drawable tree into a plot 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 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 tree, pj/membrane->plot converts it:
(def my-membrane (pj/plan->membrane my-plan))(vector? my-membrane)true(first (pj/membrane->plot 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->plot defmethod.
Pseudocode:
(ns mylib.render.canvas
(:require [scicloj.plotje.impl.render :as render]
[scicloj.plotje.render.membrane :as membrane]))
(defmethod render/membrane->plot :canvas [membrane-tree _ opts]
;; Walk the same drawable tree, emit canvas draw calls
(canvas-walk membrane-tree))
;; Also register plan->plot to orchestrate the full path:
(defmethod render/plan->plot :canvas [plan _ opts]
(let [mt (membrane/plan->membrane plan)]
(render/membrane->plot 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.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])#object[clojure.lang.MultiFn 0x5f2b7bdf "clojure.lang.MultiFn@5f2b7bdf"](pj/stat-doc :quantile)"(no description)"Cleanup
Remove the example extension so it does not affect other tests:
(remove-method stat/compute-stat :quantile)#object[clojure.lang.MultiFn 0x5f2b7bdf "clojure.lang.MultiFn@5f2b7bdf"](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