30 Extension Example: Waterfall Chart
This notebook walks through building a custom chart type from scratch β a waterfall chart that shows running totals as colored bars (green for increases, red for decreases).
It uses all three extension points needed for a new mark:
compute-statβ transform raw values into cumulative barsextract-layerβ convert stat output into plan geometrylayer->membraneβ render bars as membrane drawables
(ns plotje-book.waterfall-extension
(:require
;; Kindly -- notebook rendering protocol
[scicloj.kindly.v4.kind :as kind]
;; Plotje -- public API
[scicloj.plotje.api :as pj]
;; Extension points -- the multimethods we will extend
[scicloj.plotje.impl.stat :as stat]
[scicloj.plotje.impl.extract :as extract]
[scicloj.plotje.render.mark :as mark]
;; Layer-type registry -- to register our new chart type
[scicloj.plotje.layer-type :as layer-type]
;; Membrane -- drawing primitives
[membrane.ui :as ui]
;; Tablecloth -- dataset operations
[tablecloth.api :as tc]))Sample Data
A simple profit-and-loss waterfall: revenue flows through costs to net income.
(def pnl-data
{:category ["Revenue" "COGS" "Gross Profit" "OpEx" "Tax" "Net Income"]
:amount [500 -300 200 -120 -30 50]})Step 1: The Stat
compute-stat transforms raw data into the shape the extractor needs. For a waterfall, we compute running totals and determine the start and end of each bar.
The stat must always return :x-domain and :y-domain. Beyond that, the shape is a contract between the stat and its paired extractor β here we use :waterfall-bars.
(defmethod stat/compute-stat :waterfall [{:keys [data x y x-type] :as draft-layer}]
(let [clean (tc/drop-missing data [x y])
categories (vec (distinct (clean x)))
values (vec (clean y))
;; Running total: each bar starts where the previous ended
ends (vec (reductions + values))
starts (vec (cons 0 (butlast ends)))
bars (mapv (fn [cat s e v]
{:category cat
:start (double s)
:end (double e)
:value (double v)})
categories starts ends values)
y-min (min 0.0 (apply min (concat starts ends)))
y-max (apply max (concat starts ends))]
{:waterfall-bars bars
:categories categories
:x-domain categories
:y-domain [y-min y-max]}))Test the stat in isolation β always a good idea before wiring into the full pipeline:
(stat/compute-stat {:stat :waterfall :data (tc/dataset pnl-data) :x :category :y :amount :x-type :categorical}){:waterfall-bars
[{:category "Revenue", :start 0.0, :end 500.0, :value 500.0}
{:category "COGS", :start 500.0, :end 200.0, :value -300.0}
{:category "Gross Profit", :start 200.0, :end 400.0, :value 200.0}
{:category "OpEx", :start 400.0, :end 280.0, :value -120.0}
{:category "Tax", :start 280.0, :end 250.0, :value -30.0}
{:category "Net Income", :start 250.0, :end 300.0, :value 50.0}],
:categories
["Revenue" "COGS" "Gross Profit" "OpEx" "Tax" "Net Income"],
:x-domain ["Revenue" "COGS" "Gross Profit" "OpEx" "Tax" "Net Income"],
:y-domain [0 500]}Step 2: The Extractor
extract-layer converts the stat output into a plan layer descriptor β a plain map with :mark, :style, and geometry data. Colors are resolved here using the libraryβs color system.
We color bars green for positive amounts and red for negative.
(defmethod extract/extract-layer :waterfall [draft-layer stat all-colors cfg]
(let [bars (:waterfall-bars stat)
green [0.2 0.7 0.3 1.0]
red [0.85 0.25 0.25 1.0]]
{:mark :waterfall
:style {:opacity 0.85}
:categories (:categories stat)
:bars (mapv (fn [{:keys [category start end value]}]
{:category category
:start start
:end end
:color (if (>= value 0) green red)})
bars)}))Step 3: The Renderer
layer->membrane turns the plan layer into membrane drawable primitives. The rendering context (ctx) provides:
:sxβ the x scale function mapping data value to pixel x:syβ the y scale function mapping data value to pixel y:panel-height,:panel-width,:marginβ layout dimensions
For a band (categorical) x-scale, (sx category true) returns band info including :rstart, :rend, and :point.
(defmethod mark/layer->membrane :waterfall [layer ctx]
(let [{:keys [bars style]} layer
{:keys [sx sy coord-fn]} ctx
{:keys [opacity]} style
;; Get band info from the scale to find bar width.
;; (sx category true) returns {:rstart :rend :point} for that band.
sample-info (sx (-> bars first :category) true)
bw (- (:rend sample-info) (:rstart sample-info))
w (* 0.8 bw)]
(vec
(for [{:keys [category start end color]} bars
:let [[cr cg cb ca] color
;; Use the band info for precise positioning
band (sx category true)
mid-x (:point band)
py-start (double (sy start))
py-end (double (sy end))
top (min py-start py-end)
bot (max py-start py-end)
x0 (- mid-x (/ w 2.0))
x1 (+ mid-x (/ w 2.0))]]
(ui/with-color [cr cg cb (or opacity ca)]
(ui/with-style :membrane.ui/style-fill
(ui/path [x0 top] [x1 top] [x1 bot] [x0 bot])))))))Step 4: Register and Plot
Register the layer type so the pipeline recognizes :waterfall:
(layer-type/register! :waterfall
{:mark :waterfall :stat :waterfall
:doc "Waterfall -- running total with increase/decrease bars."}):waterfallNow we can plot it using the pose API. Since there is no built-in pj/lay-waterfall, we use pj/lay with the layer-type lookup.
We use pj/plot to force eager rendering to SVG before the cleanup section at the end of this notebook removes the extensionβs defmethods:
(-> pnl-data
(pj/pose :category :amount)
(pj/lay (layer-type/lookup :waterfall))
(pj/options {:title "Profit & Loss Waterfall"
:width 500 :height 350})
pj/plot)Six bars β one per category. Green for positive amounts (Revenue, Gross Profit, Net Income), red for negative (COGS, OpEx, Tax).
Optional: Convenience Function
For a polished API, wrap the pattern in a pose-compatible function:
(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)))))Now the call is as clean as any built-in layer type:
(-> pnl-data
(lay-waterfall :category :amount)
(pj/options {:title "Quarterly Cash Flow" :width 500})
pj/plot)Optional: Self-Documenting Extension
Register [:key :doc] defmethods so the extension appears in the generated tables in the Extensibility chapter:
(defmethod stat/compute-stat [:waterfall :doc] [_]
"Compute running totals for waterfall bars")(defmethod extract/extract-layer [:waterfall :doc] [_ _ _ _]
"Colored bars (green/red) with start/end positions")(defmethod mark/layer->membrane [:waterfall :doc] [_ _]
"Filled rectangles positioned by running total")(pj/stat-doc :waterfall)"Compute running totals for waterfall bars"Cleanup
Remove the extension so it does not affect other notebooks:
(remove-method stat/compute-stat :waterfall)#object[clojure.lang.MultiFn 0x5f2b7bdf "clojure.lang.MultiFn@5f2b7bdf"](remove-method stat/compute-stat [:waterfall :doc])#object[clojure.lang.MultiFn 0x5f2b7bdf "clojure.lang.MultiFn@5f2b7bdf"](remove-method extract/extract-layer :waterfall)#object[clojure.lang.MultiFn 0x7a24ec84 "clojure.lang.MultiFn@7a24ec84"](remove-method extract/extract-layer [:waterfall :doc])#object[clojure.lang.MultiFn 0x7a24ec84 "clojure.lang.MultiFn@7a24ec84"](remove-method mark/layer->membrane :waterfall)#object[clojure.lang.MultiFn 0x2435b1f1 "clojure.lang.MultiFn@2435b1f1"](remove-method mark/layer->membrane [:waterfall :doc])#object[clojure.lang.MultiFn 0x2435b1f1 "clojure.lang.MultiFn@2435b1f1"](swap! @(resolve 'scicloj.plotje.layer-type/registry*) dissoc :waterfall){:smooth
{:mark :line,
:stat :loess,
:accepts
[:confidence-band
:bootstrap-resamples
:bandwidth
:size
:nudge-x
:nudge-y],
:doc
"Smoothed trend line β defaults to LOESS; pass {:stat :linear-model} for OLS."},
:tile
{:mark :tile,
:stat :bin2d,
:accepts [:fill :density-2d-grid],
:doc "Tile/heatmap β 2D grid binning."},
:errorbar
{:mark :errorbar,
:stat :identity,
:accepts [:y-min :y-max :size :nudge-x :nudge-y],
:doc "Errorbar β vertical error bars."},
:histogram
{:mark :bar,
:stat :bin,
:x-only true,
:accepts [:normalize :bins :binwidth],
:doc "Histogram β bins numerical data into bars."},
:ridgeline
{:mark :ridgeline,
:stat :violin,
:accepts [:bandwidth],
:doc "Ridgeline β stacked density curves per category."},
:interval-h
{:mark :interval-h,
:stat :identity,
:accepts [:x-end :interval-thickness],
:rejects [:position],
:doc
"Interval β horizontal bars from x to x-end at categorical y. For Gantt-style timelines."},
:value-bar
{:mark :rect,
:stat :identity,
:accepts [],
:doc "Value bar β categorical x with pre-computed y."},
:bar
{:mark :rect,
:stat :count,
:x-only true,
:accepts [],
:doc "Bar β counts categorical values."},
:band-h
{:mark :band-h,
:stat :identity,
:accepts [:y-min :y-max],
:rejects [:position :group :x-type :y-type :color-type],
:doc "Horizontal shaded band between y = y-min and y = y-max."},
:rule-h
{:mark :rule-h,
:stat :identity,
:accepts [:y-intercept],
:rejects [:position :group :x-type :y-type :color-type],
:doc "Horizontal reference line at y = y-intercept."},
:rule-v
{:mark :rule-v,
:stat :identity,
:accepts [:x-intercept],
:rejects [:position :group :x-type :y-type :color-type],
:doc "Vertical reference line at x = x-intercept."},
:summary
{:mark :pointrange,
:stat :summary,
:accepts [:size],
:doc "Summary β mean Β± standard error per category."},
:lollipop
{:mark :lollipop,
:stat :identity,
:accepts [:size],
:doc "Lollipop β stem with dot."},
:line
{:mark :line,
:stat :identity,
:accepts [:size :nudge-x :nudge-y],
:doc "Line β connects data points in order."},
:label
{:mark :label,
:stat :identity,
:accepts [:text :nudge-x :nudge-y],
:doc "Label β text with background box."},
:band-v
{:mark :band-v,
:stat :identity,
:accepts [:x-min :x-max],
:rejects [:position :group :x-type :y-type :color-type],
:doc "Vertical shaded band between x = x-min and x = x-max."},
:violin
{:mark :violin,
:stat :violin,
:accepts [:bandwidth :size],
:doc "Violin β mirrored density curve per category."},
:point
{:mark :point,
:stat :identity,
:accepts [:size :shape :jitter :text :nudge-x :nudge-y],
:doc "Scatter β individual data points."},
:area
{:mark :area,
:stat :identity,
:accepts [],
:doc "Area β filled region under a line."},
:contour
{:mark :contour,
:stat :density-2d,
:accepts [:levels :size],
:doc "Contour β iso-density contour lines."},
:density
{:mark :area,
:stat :density,
:x-only true,
:accepts [:bandwidth],
:doc "Density β KDE (kernel density estimation) as filled area."},
:density-2d
{:mark :tile,
:stat :density-2d,
:accepts [:density-2d-grid],
:doc
"2D density β kernel density estimation (KDE) smoothed heatmap."},
:step
{:mark :step,
:stat :identity,
:accepts [:size],
:doc "Step β horizontal-then-vertical connected points."},
:boxplot
{:mark :boxplot,
:stat :boxplot,
:accepts [:size],
:doc "Boxplot β median, quartiles, whiskers, outliers."},
:rug
{:mark :rug,
:stat :identity,
:x-only true,
:accepts [:side],
:doc "Rug β axis-margin tick marks."},
:text
{:mark :text,
:stat :identity,
:accepts [:text :nudge-x :nudge-y],
:doc "Text β data-driven labels."}}Verify cleanup:
(nil? (layer-type/lookup :waterfall))trueBackground
- Extensibility β reference for all eight extension points
- Architecture β the five-stage pipeline in detail
Whatβs Next
- Edge Cases β how the library handles unusual inputs