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:

  1. compute-stat β€” transform raw values into cumulative bars
  2. extract-layer β€” convert stat output into plan geometry
  3. layer->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."})
:waterfall

Now 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}))
Profit & Loss WaterfallamountcategoryRevenueCOGSGross ProfitOpExTaxNet Income0100200300400500

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}))
Quarterly Cash FlowamountcategoryRevenueCOGSGross ProfitOpExTaxNet Income050100150200250300350400450500

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

What’s Next

source: notebooks/napkinsketch_book/waterfall_extension.clj