24 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 demonstrates 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
After reading this, you should be able to add any custom chart type to napkinsketch.
(ns napkinsketch-book.waterfall-extension
(:require
;; Kindly β notebook rendering protocol
[scicloj.kindly.v4.kind :as kind]
;; Napkinsketch β public API
[scicloj.napkinsketch.api :as sk]
;; Extension points β the multimethods we will extend
[scicloj.napkinsketch.impl.stat :as stat]
[scicloj.napkinsketch.impl.extract :as extract]
[scicloj.napkinsketch.render.mark :as mark]
;; Method registry β to register our new chart type
[scicloj.napkinsketch.method :as method]
;; 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 view}]
(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 [view 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 (data value β pixel x):syβ the y scale function (data value β pixel y):panel-height,:panel-width,:marginβ layout dimensions
For a band (categorical) x-scale, (ws/data sx :bandwidth) gives the bar width.
(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 method so the pipeline recognizes :waterfall:
(method/register! :waterfall
{:mark :waterfall :stat :waterfall
:doc "Waterfall β running total with increase/decrease bars."}):waterfallNow we can plot it. Since there is no built-in sk/lay-waterfall, we use sk/lay with the method lookup.
Normally you would use sk/options instead of sk/plot β that keeps the result as a composable sketch that auto-renders in notebooks. Here we use sk/plot to force eager rendering to SVG before the cleanup section at the end of this notebook removes the extensionβs defmethods:
(-> pnl-data
(sk/view :category :amount)
(sk/lay (method/lookup :waterfall))
(sk/plot {:title "Profit & Loss Waterfall"
:width 500 :height 350}))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 function:
(defn lay-waterfall
([views] (sk/lay views (method/lookup :waterfall)))
([data x y] (-> data (sk/view x y) (sk/lay (method/lookup :waterfall))))
([data x y opts] (-> data (sk/view x y) (sk/lay (merge (method/lookup :waterfall) opts)))))Now the call is as clean as any built-in method:
(-> pnl-data
(lay-waterfall :category :amount)
(sk/plot {:title "Quarterly Cash Flow" :width 500}))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")(sk/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)#multifn[compute-stat 0x4ff98410](remove-method stat/compute-stat [:waterfall :doc])#multifn[compute-stat 0x4ff98410](remove-method extract/extract-layer :waterfall)#multifn[extract-layer 0x51e4b7ff](remove-method extract/extract-layer [:waterfall :doc])#multifn[extract-layer 0x51e4b7ff](remove-method mark/layer->membrane :waterfall)#multifn[layer->membrane 0x3c9b1051](remove-method mark/layer->membrane [:waterfall :doc])#multifn[layer->membrane 0x3c9b1051](swap! @(resolve 'scicloj.napkinsketch.method/registry*) dissoc :waterfall){:tile
{:mark :tile,
:stat :bin2d,
:accepts [:fill :kde2d-grid],
:doc "Tile/heatmap β 2D grid binning."},
:errorbar
{:mark :errorbar,
:stat :identity,
:accepts [:ymin :ymax :size :nudge-x :nudge-y],
:doc "Errorbar β vertical error bars."},
:stacked-area
{:mark :area,
:stat :identity,
:position :stack,
:accepts [],
:doc "Stacked area β filled regions stacked cumulatively."},
:histogram
{:mark :bar,
:stat :bin,
:x-only true,
:accepts [:normalize],
:doc "Histogram β bins numerical data into bars."},
:ridgeline
{:mark :ridgeline,
:stat :violin,
:accepts [:bandwidth],
:doc "Ridgeline β stacked density curves per category."},
: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."},
:density2d
{:mark :tile,
:stat :kde2d,
:accepts [:kde2d-grid],
:doc
"2D density β kernel density estimation (KDE) smoothed heatmap."},
:lm
{:mark :line,
:stat :lm,
:accepts [:se :size :nudge-x :nudge-y],
:doc
"Linear model (lm) β ordinary least squares (OLS) regression line."},
:stacked-bar
{:mark :rect,
:stat :count,
:position :stack,
:x-only true,
:accepts [],
:doc "Stacked bar β counts categorical values, stacked."},
: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."},
:loess
{:mark :line,
:stat :loess,
:accepts [:se :se-boot :bandwidth :size :nudge-x :nudge-y],
:doc
"LOESS (local regression) β smooth curve fitted to nearby data."},
:label
{:mark :label,
:stat :identity,
:accepts [:text :nudge-x :nudge-y],
:doc "Label β text with background box."},
: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 :kde2d,
:accepts [:levels :size],
:doc "Contour β iso-density contour lines."},
:stacked-bar-fill
{:mark :rect,
:stat :count,
:position :fill,
:x-only true,
:accepts [],
:doc "Percentage stacked bar β proportions sum to 1.0."},
:density
{:mark :area,
:stat :kde,
:x-only true,
:accepts [:bandwidth],
:doc "Density β KDE (kernel density estimation) as filled area."},
: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? (method/lookup :waterfall))trueWhatβs Next
- Extensibility β reference for all seven extension points
- Architecture β the four-stage pipeline in detail