Tableplot Tutorial: Customizing Plots with Parameter Substitution

Learn how to customize Tableplot visualizations using substitution parameters.
Published

November 11, 2025

Introduction

Tableplot is a declarative plotting library that makes it easy to create interactive visualizations from tabular data. One of its most powerful features is parameter substitution - the ability to customize plot appearance and behavior by passing parameters that override defaults.

This tutorial is a brief intro to this feature.

Tableplot is inspired by the layered grammar of graphics, a framework for understanding and building statistical visualizations. Originally developed by Leland Wilkinson and later refined by Hadley Wickham in ggplot2, the grammar views plots as compositions of independent components: data, aesthetic mappings, geometric objects, scales, coordinates, and facets.

The challenge in implementing such a grammar is achieving multiple goals simultaneously:

  • Succinct: Simple things should be simple - sensible defaults for common cases
  • Declarative: Describe what you want, not how to draw it
  • Flexible: Support customization without sacrificing simplicity
  • Observable: Make the details visible and understandable when needed
  • Extensible: Allow users to work with internals without breaking abstractions

Tableplot addresses these challenges by mostly adopting Hanami’s solution as a starting point. Hanami introduced a template-based approach with substitution keys, allowing hierarchical defaults: you can rely on conventions for quick plots, or override specific details when needed. The templates approach makes the transformation process observable, as we’ll see in this tutorial.

Further reading:

A toy challenge: Customizing Grid Colors

Let’s start with a basic dataset and plot.

We will use: Kindly for annotating visualizations, Tablecloth for table processing, and Tableplot for plotting.

(require 
 '[scicloj.kindly.v4.kind :as kind]
 '[tablecloth.api :as tc]
 '[scicloj.tableplot.v1.plotly :as plotly])
(def sample-data
  (tc/dataset {:x [1 2 3 4 5]
               :y [2 4 3 5 7]}))

In this tutorial, we’ll use Tableplot’s Plotly.js API, which generates interactive Plotly.js visualizations. Tableplot also supports other backends like Vega-Lite and an experimental transpilation API.

We can make a basic plot with two layers. This is really easy with our data, because the :x and :y columns are used by default for the plot’s axes.

(-> sample-data
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line)

By default, when used in Kindly-compatible tools like Clay and in Clojure Civitas posts, Tableplot’s plots are configured to be displayed visually.

But we can also change the kind annotation, so that we can see them as plain Clojure data structures.

(-> sample-data
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line
    kind/pprint)
{:data :=traces,
 :layout :=layout,
 :aerial.hanami.templates/defaults
 {:=textfont :com.rpl.specter.impl/NONE,
  :=x0 :com.rpl.specter.impl/NONE,
  :=y-type
  #object[clojure.lang.AFunction$1 0x7b9b664f "clojure.lang.AFunction$1@7b9b664f"],
  :=coordinates :2d,
  :=boxmode :com.rpl.specter.impl/NONE,
  :=x0-after-stat :=x0,
  :=z-after-stat :=z,
  :=splom-traces
  #object[clojure.lang.AFunction$1 0x35c02fbe "clojure.lang.AFunction$1@35c02fbe"],
  :=zmax :com.rpl.specter.impl/NONE,
  :=layers
  [{:y :=y-after-stat,
    :trace-base
    {:mode :=mode,
     :type :=type,
     :opacity :=mark-opacity,
     :textfont :=textfont},
    :colorscale :=colorscale,
    :color-type :=color-type,
    :r :=r,
    :coordinates :=coordinates,
    :group :=group,
    :color :=color,
    :meanline-visible :=meanline-visible,
    :mark :=mark,
    :x-title :=x-title,
    :symbol :=symbol,
    :name :=name,
    :fill :=mark-fill,
    :y1 :=y1-after-stat,
    :bar-width :=bar-width,
    :boxmode :=boxmode,
    :size-range :=size-range,
    :theta :=theta,
    :size :=size,
    :size-type :=size-type,
    :z :=z-after-stat,
    :lon :=lon,
    :aerial.hanami.templates/defaults
    {:=textfont :com.rpl.specter.impl/NONE,
     :=x0 :com.rpl.specter.impl/NONE,
     :=y-type
     #object[clojure.lang.AFunction$1 0x7b9b664f "clojure.lang.AFunction$1@7b9b664f"],
     :=coordinates :2d,
     :=boxmode :com.rpl.specter.impl/NONE,
     :=x0-after-stat :=x0,
     :=z-after-stat :=z,
     :=splom-traces
     #object[clojure.lang.AFunction$1 0x35c02fbe "clojure.lang.AFunction$1@35c02fbe"],
     :=zmax :com.rpl.specter.impl/NONE,
     :=layers [],
     :=mark-fill :com.rpl.specter.impl/NONE,
     :=x1 :com.rpl.specter.impl/NONE,
     :=title :com.rpl.specter.impl/NONE,
     :=annotations :com.rpl.specter.impl/NONE,
     :=z-type
     #object[clojure.lang.AFunction$1 0x63f104f9 "clojure.lang.AFunction$1@63f104f9"],
     :=y1 :com.rpl.specter.impl/NONE,
     :=y-type-after-stat
     #object[clojure.lang.AFunction$1 0x68bfb5d9 "clojure.lang.AFunction$1@68bfb5d9"],
     :=height 400,
     :=box-visible :com.rpl.specter.impl/NONE,
     :=mark-symbol :com.rpl.specter.impl/NONE,
     :=name :com.rpl.specter.impl/NONE,
     :=mark-opacity :com.rpl.specter.impl/NONE,
     :=inferred-group
     #object[clojure.lang.AFunction$1 0x5c36daaf "clojure.lang.AFunction$1@5c36daaf"],
     :=y-showgrid true,
     :=density-bandwidth :com.rpl.specter.impl/NONE,
     :=mode
     #object[clojure.lang.AFunction$1 0x370bcb1c "clojure.lang.AFunction$1@370bcb1c"],
     :=splom-layout
     #object[clojure.lang.AFunction$1 0x4f99cc08 "clojure.lang.AFunction$1@4f99cc08"],
     :=y-title :com.rpl.specter.impl/NONE,
     :=z-type-after-stat
     #object[clojure.lang.AFunction$1 0x5a1da95f "clojure.lang.AFunction$1@5a1da95f"],
     :=size :com.rpl.specter.impl/NONE,
     :=model-options {:model-type :metamorph.ml/ols},
     :=group :=inferred-group,
     :=y0 :com.rpl.specter.impl/NONE,
     :=mark-size 20,
     :=violinmode :com.rpl.specter.impl/NONE,
     :=design-matrix
     #object[clojure.lang.AFunction$1 0x3014f311 "clojure.lang.AFunction$1@3014f311"],
     :=size-type
     #object[clojure.lang.AFunction$1 0x59ae8d24 "clojure.lang.AFunction$1@59ae8d24"],
     :=zmin :com.rpl.specter.impl/NONE,
     :=x-showgrid true,
     :=r :com.rpl.specter.impl/NONE,
     :=color :com.rpl.specter.impl/NONE,
     :=mark-color :com.rpl.specter.impl/NONE,
     :=bar-width :com.rpl.specter.impl/NONE,
     :=y1-after-stat :=y1,
     :=x :x,
     :=symbol :com.rpl.specter.impl/NONE,
     :=x-after-stat :=x,
     :=yaxis-gridcolor "rgb(255,255,255)",
     :=lon :com.rpl.specter.impl/NONE,
     :=text :com.rpl.specter.impl/NONE,
     :=type
     #object[clojure.lang.AFunction$1 0x58d6e395 "clojure.lang.AFunction$1@58d6e395"],
     :=x-type-after-stat
     #object[clojure.lang.AFunction$1 0x6fb6ee81 "clojure.lang.AFunction$1@6fb6ee81"],
     :=traces
     #object[clojure.lang.AFunction$1 0x78ad141d "clojure.lang.AFunction$1@78ad141d"],
     :=x-type
     #object[clojure.lang.AFunction$1 0x55c76181 "clojure.lang.AFunction$1@55c76181"],
     :=histogram-nbins 10,
     :=automargin false,
     :=stat :=dataset,
     :=z :z,
     :=width 500,
     :=lat :com.rpl.specter.impl/NONE,
     :=margin {:t 25},
     :=color-type
     #object[clojure.lang.AFunction$1 0x3ae2eae7 "clojure.lang.AFunction$1@3ae2eae7"],
     :=xaxis-gridcolor "rgb(255,255,255)",
     :=mark :point,
     :=size-range [10 30],
     :=x-title :com.rpl.specter.impl/NONE,
     :=colorscale :com.rpl.specter.impl/NONE,
     :=layout
     #object[clojure.lang.AFunction$1 0x62f8d9dc "clojure.lang.AFunction$1@62f8d9dc"],
     :=colnames
     #object[clojure.lang.AFunction$1 0x72652462 "clojure.lang.AFunction$1@72652462"],
     :=y :y,
     :=x1-after-stat :=x1,
     :=dataset _unnamed [5 2]:

| :x | :y |
|---:|---:|
|  1 |  2 |
|  2 |  4 |
|  3 |  3 |
|  4 |  5 |
|  5 |  7 |
,
     :=background "rgb(235,235,235)",
     :=theta :com.rpl.specter.impl/NONE,
     :=y0-after-stat :=y0,
     :=y-after-stat :=y,
     :=predictors [:=x],
     :=marker-size-key
     #object[clojure.lang.AFunction$1 0x1cc098c6 "clojure.lang.AFunction$1@1cc098c6"],
     :=meanline-visible :com.rpl.specter.impl/NONE},
    :lat :=lat,
    :y0 :=y0-after-stat,
    :zmax :=zmax,
    :annotations :=annotations,
    :inferred-group :=inferred-group,
    :marker-override
    {:color :=mark-color,
     :=marker-size-key :=mark-size,
     :symbol :=mark-symbol,
     :colorscale :=colorscale},
    :x :=x-after-stat,
    :x1 :=x1-after-stat,
    :x0 :=x0-after-stat,
    :zmin :=zmin,
    :y-title :=y-title,
    :box-visible :=box-visible,
    :dataset :=stat,
    :violinmode :=violinmode,
    :text :=text}
   {:y :=y-after-stat,
    :trace-base
    {:mode :=mode,
     :type :=type,
     :opacity :=mark-opacity,
     :textfont :=textfont},
    :colorscale :=colorscale,
    :color-type :=color-type,
    :r :=r,
    :coordinates :=coordinates,
    :group :=group,
    :color :=color,
    :meanline-visible :=meanline-visible,
    :mark :=mark,
    :x-title :=x-title,
    :symbol :=symbol,
    :name :=name,
    :fill :=mark-fill,
    :y1 :=y1-after-stat,
    :bar-width :=bar-width,
    :boxmode :=boxmode,
    :size-range :=size-range,
    :theta :=theta,
    :size :=size,
    :size-type :=size-type,
    :z :=z-after-stat,
    :lon :=lon,
    :aerial.hanami.templates/defaults
    {:=textfont :com.rpl.specter.impl/NONE,
     :=x0 :com.rpl.specter.impl/NONE,
     :=y-type
     #object[clojure.lang.AFunction$1 0x7b9b664f "clojure.lang.AFunction$1@7b9b664f"],
     :=coordinates :2d,
     :=boxmode :com.rpl.specter.impl/NONE,
     :=x0-after-stat :=x0,
     :=z-after-stat :=z,
     :=splom-traces
     #object[clojure.lang.AFunction$1 0x35c02fbe "clojure.lang.AFunction$1@35c02fbe"],
     :=zmax :com.rpl.specter.impl/NONE,
     :=layers
     [{:y :=y-after-stat,
       :trace-base
       {:mode :=mode,
        :type :=type,
        :opacity :=mark-opacity,
        :textfont :=textfont},
       :colorscale :=colorscale,
       :color-type :=color-type,
       :r :=r,
       :coordinates :=coordinates,
       :group :=group,
       :color :=color,
       :meanline-visible :=meanline-visible,
       :mark :=mark,
       :x-title :=x-title,
       :symbol :=symbol,
       :name :=name,
       :fill :=mark-fill,
       :y1 :=y1-after-stat,
       :bar-width :=bar-width,
       :boxmode :=boxmode,
       :size-range :=size-range,
       :theta :=theta,
       :size :=size,
       :size-type :=size-type,
       :z :=z-after-stat,
       :lon :=lon,
       :aerial.hanami.templates/defaults
       {:=textfont :com.rpl.specter.impl/NONE,
        :=x0 :com.rpl.specter.impl/NONE,
        :=y-type
        #object[clojure.lang.AFunction$1 0x7b9b664f "clojure.lang.AFunction$1@7b9b664f"],
        :=coordinates :2d,
        :=boxmode :com.rpl.specter.impl/NONE,
        :=x0-after-stat :=x0,
        :=z-after-stat :=z,
        :=splom-traces
        #object[clojure.lang.AFunction$1 0x35c02fbe "clojure.lang.AFunction$1@35c02fbe"],
        :=zmax :com.rpl.specter.impl/NONE,
        :=layers [],
        :=mark-fill :com.rpl.specter.impl/NONE,
        :=x1 :com.rpl.specter.impl/NONE,
        :=title :com.rpl.specter.impl/NONE,
        :=annotations :com.rpl.specter.impl/NONE,
        :=z-type
        #object[clojure.lang.AFunction$1 0x63f104f9 "clojure.lang.AFunction$1@63f104f9"],
        :=y1 :com.rpl.specter.impl/NONE,
        :=y-type-after-stat
        #object[clojure.lang.AFunction$1 0x68bfb5d9 "clojure.lang.AFunction$1@68bfb5d9"],
        :=height 400,
        :=box-visible :com.rpl.specter.impl/NONE,
        :=mark-symbol :com.rpl.specter.impl/NONE,
        :=name :com.rpl.specter.impl/NONE,
        :=mark-opacity :com.rpl.specter.impl/NONE,
        :=inferred-group
        #object[clojure.lang.AFunction$1 0x5c36daaf "clojure.lang.AFunction$1@5c36daaf"],
        :=y-showgrid true,
        :=density-bandwidth :com.rpl.specter.impl/NONE,
        :=mode
        #object[clojure.lang.AFunction$1 0x370bcb1c "clojure.lang.AFunction$1@370bcb1c"],
        :=splom-layout
        #object[clojure.lang.AFunction$1 0x4f99cc08 "clojure.lang.AFunction$1@4f99cc08"],
        :=y-title :com.rpl.specter.impl/NONE,
        :=z-type-after-stat
        #object[clojure.lang.AFunction$1 0x5a1da95f "clojure.lang.AFunction$1@5a1da95f"],
        :=size :com.rpl.specter.impl/NONE,
        :=model-options {:model-type :metamorph.ml/ols},
        :=group :=inferred-group,
        :=y0 :com.rpl.specter.impl/NONE,
        :=mark-size 20,
        :=violinmode :com.rpl.specter.impl/NONE,
        :=design-matrix
        #object[clojure.lang.AFunction$1 0x3014f311 "clojure.lang.AFunction$1@3014f311"],
        :=size-type
        #object[clojure.lang.AFunction$1 0x59ae8d24 "clojure.lang.AFunction$1@59ae8d24"],
        :=zmin :com.rpl.specter.impl/NONE,
        :=x-showgrid true,
        :=r :com.rpl.specter.impl/NONE,
        :=color :com.rpl.specter.impl/NONE,
        :=mark-color :com.rpl.specter.impl/NONE,
        :=bar-width :com.rpl.specter.impl/NONE,
        :=y1-after-stat :=y1,
        :=x :x,
        :=symbol :com.rpl.specter.impl/NONE,
        :=x-after-stat :=x,
        :=yaxis-gridcolor "rgb(255,255,255)",
        :=lon :com.rpl.specter.impl/NONE,
        :=text :com.rpl.specter.impl/NONE,
        :=type
        #object[clojure.lang.AFunction$1 0x58d6e395 "clojure.lang.AFunction$1@58d6e395"],
        :=x-type-after-stat
        #object[clojure.lang.AFunction$1 0x6fb6ee81 "clojure.lang.AFunction$1@6fb6ee81"],
        :=traces
        #object[clojure.lang.AFunction$1 0x78ad141d "clojure.lang.AFunction$1@78ad141d"],
        :=x-type
        #object[clojure.lang.AFunction$1 0x55c76181 "clojure.lang.AFunction$1@55c76181"],
        :=histogram-nbins 10,
        :=automargin false,
        :=stat :=dataset,
        :=z :z,
        :=width 500,
        :=lat :com.rpl.specter.impl/NONE,
        :=margin {:t 25},
        :=color-type
        #object[clojure.lang.AFunction$1 0x3ae2eae7 "clojure.lang.AFunction$1@3ae2eae7"],
        :=xaxis-gridcolor "rgb(255,255,255)",
        :=mark :point,
        :=size-range [10 30],
        :=x-title :com.rpl.specter.impl/NONE,
        :=colorscale :com.rpl.specter.impl/NONE,
        :=layout
        #object[clojure.lang.AFunction$1 0x62f8d9dc "clojure.lang.AFunction$1@62f8d9dc"],
        :=colnames
        #object[clojure.lang.AFunction$1 0x72652462 "clojure.lang.AFunction$1@72652462"],
        :=y :y,
        :=x1-after-stat :=x1,
        :=dataset _unnamed [5 2]:

| :x | :y |
|---:|---:|
|  1 |  2 |
|  2 |  4 |
|  3 |  3 |
|  4 |  5 |
|  5 |  7 |
,
        :=background "rgb(235,235,235)",
        :=theta :com.rpl.specter.impl/NONE,
        :=y0-after-stat :=y0,
        :=y-after-stat :=y,
        :=predictors [:=x],
        :=marker-size-key
        #object[clojure.lang.AFunction$1 0x1cc098c6 "clojure.lang.AFunction$1@1cc098c6"],
        :=meanline-visible :com.rpl.specter.impl/NONE},
       :lat :=lat,
       :y0 :=y0-after-stat,
       :zmax :=zmax,
       :annotations :=annotations,
       :inferred-group :=inferred-group,
       :marker-override
       {:color :=mark-color,
        :=marker-size-key :=mark-size,
        :symbol :=mark-symbol,
        :colorscale :=colorscale},
       :x :=x-after-stat,
       :x1 :=x1-after-stat,
       :x0 :=x0-after-stat,
       :zmin :=zmin,
       :y-title :=y-title,
       :box-visible :=box-visible,
       :dataset :=stat,
       :violinmode :=violinmode,
       :text :=text}],
     :=mark-fill :com.rpl.specter.impl/NONE,
     :=x1 :com.rpl.specter.impl/NONE,
     :=title :com.rpl.specter.impl/NONE,
     :=annotations :com.rpl.specter.impl/NONE,
     :=z-type
     #object[clojure.lang.AFunction$1 0x63f104f9 "clojure.lang.AFunction$1@63f104f9"],
     :=y1 :com.rpl.specter.impl/NONE,
     :=y-type-after-stat
     #object[clojure.lang.AFunction$1 0x68bfb5d9 "clojure.lang.AFunction$1@68bfb5d9"],
     :=height 400,
     :=box-visible :com.rpl.specter.impl/NONE,
     :=mark-symbol :com.rpl.specter.impl/NONE,
     :=name :com.rpl.specter.impl/NONE,
     :=mark-opacity :com.rpl.specter.impl/NONE,
     :=inferred-group
     #object[clojure.lang.AFunction$1 0x5c36daaf "clojure.lang.AFunction$1@5c36daaf"],
     :=y-showgrid true,
     :=density-bandwidth :com.rpl.specter.impl/NONE,
     :=mode
     #object[clojure.lang.AFunction$1 0x370bcb1c "clojure.lang.AFunction$1@370bcb1c"],
     :=splom-layout
     #object[clojure.lang.AFunction$1 0x4f99cc08 "clojure.lang.AFunction$1@4f99cc08"],
     :=y-title :com.rpl.specter.impl/NONE,
     :=z-type-after-stat
     #object[clojure.lang.AFunction$1 0x5a1da95f "clojure.lang.AFunction$1@5a1da95f"],
     :=size :com.rpl.specter.impl/NONE,
     :=model-options {:model-type :metamorph.ml/ols},
     :=group :=inferred-group,
     :=y0 :com.rpl.specter.impl/NONE,
     :=mark-size :com.rpl.specter.impl/NONE,
     :=violinmode :com.rpl.specter.impl/NONE,
     :=design-matrix
     #object[clojure.lang.AFunction$1 0x3014f311 "clojure.lang.AFunction$1@3014f311"],
     :=size-type
     #object[clojure.lang.AFunction$1 0x59ae8d24 "clojure.lang.AFunction$1@59ae8d24"],
     :=zmin :com.rpl.specter.impl/NONE,
     :=x-showgrid true,
     :=r :com.rpl.specter.impl/NONE,
     :=color :com.rpl.specter.impl/NONE,
     :=mark-color :com.rpl.specter.impl/NONE,
     :=bar-width :com.rpl.specter.impl/NONE,
     :=y1-after-stat :=y1,
     :=x :x,
     :=symbol :com.rpl.specter.impl/NONE,
     :=x-after-stat :=x,
     :=yaxis-gridcolor "rgb(255,255,255)",
     :=lon :com.rpl.specter.impl/NONE,
     :=text :com.rpl.specter.impl/NONE,
     :=type
     #object[clojure.lang.AFunction$1 0x58d6e395 "clojure.lang.AFunction$1@58d6e395"],
     :=x-type-after-stat
     #object[clojure.lang.AFunction$1 0x6fb6ee81 "clojure.lang.AFunction$1@6fb6ee81"],
     :=traces
     #object[clojure.lang.AFunction$1 0x78ad141d "clojure.lang.AFunction$1@78ad141d"],
     :=x-type
     #object[clojure.lang.AFunction$1 0x55c76181 "clojure.lang.AFunction$1@55c76181"],
     :=histogram-nbins 10,
     :=automargin false,
     :=stat :=dataset,
     :=z :z,
     :=width 500,
     :=lat :com.rpl.specter.impl/NONE,
     :=margin {:t 25},
     :=color-type
     #object[clojure.lang.AFunction$1 0x3ae2eae7 "clojure.lang.AFunction$1@3ae2eae7"],
     :=xaxis-gridcolor "rgb(255,255,255)",
     :=mark :line,
     :=size-range [10 30],
     :=x-title :com.rpl.specter.impl/NONE,
     :=colorscale :com.rpl.specter.impl/NONE,
     :=layout
     #object[clojure.lang.AFunction$1 0x62f8d9dc "clojure.lang.AFunction$1@62f8d9dc"],
     :=colnames
     #object[clojure.lang.AFunction$1 0x72652462 "clojure.lang.AFunction$1@72652462"],
     :=y :y,
     :=x1-after-stat :=x1,
     :=dataset _unnamed [5 2]:

| :x | :y |
|---:|---:|
|  1 |  2 |
|  2 |  4 |
|  3 |  3 |
|  4 |  5 |
|  5 |  7 |
,
     :=background "rgb(235,235,235)",
     :=theta :com.rpl.specter.impl/NONE,
     :=y0-after-stat :=y0,
     :=y-after-stat :=y,
     :=predictors [:=x],
     :=marker-size-key
     #object[clojure.lang.AFunction$1 0x1cc098c6 "clojure.lang.AFunction$1@1cc098c6"],
     :=meanline-visible :com.rpl.specter.impl/NONE},
    :lat :=lat,
    :y0 :=y0-after-stat,
    :zmax :=zmax,
    :annotations :=annotations,
    :inferred-group :=inferred-group,
    :marker-override
    {:color :=mark-color,
     :=marker-size-key :=mark-size,
     :symbol :=mark-symbol,
     :colorscale :=colorscale},
    :x :=x-after-stat,
    :x1 :=x1-after-stat,
    :x0 :=x0-after-stat,
    :zmin :=zmin,
    :y-title :=y-title,
    :box-visible :=box-visible,
    :dataset :=stat,
    :violinmode :=violinmode,
    :text :=text}],
  :=mark-fill :com.rpl.specter.impl/NONE,
  :=x1 :com.rpl.specter.impl/NONE,
  :=title :com.rpl.specter.impl/NONE,
  :=annotations :com.rpl.specter.impl/NONE,
  :=z-type
  #object[clojure.lang.AFunction$1 0x63f104f9 "clojure.lang.AFunction$1@63f104f9"],
  :=y1 :com.rpl.specter.impl/NONE,
  :=y-type-after-stat
  #object[clojure.lang.AFunction$1 0x68bfb5d9 "clojure.lang.AFunction$1@68bfb5d9"],
  :=height 400,
  :=box-visible :com.rpl.specter.impl/NONE,
  :=mark-symbol :com.rpl.specter.impl/NONE,
  :=name :com.rpl.specter.impl/NONE,
  :=mark-opacity :com.rpl.specter.impl/NONE,
  :=inferred-group
  #object[clojure.lang.AFunction$1 0x5c36daaf "clojure.lang.AFunction$1@5c36daaf"],
  :=y-showgrid true,
  :=density-bandwidth :com.rpl.specter.impl/NONE,
  :=mode
  #object[clojure.lang.AFunction$1 0x370bcb1c "clojure.lang.AFunction$1@370bcb1c"],
  :=splom-layout
  #object[clojure.lang.AFunction$1 0x4f99cc08 "clojure.lang.AFunction$1@4f99cc08"],
  :=y-title :com.rpl.specter.impl/NONE,
  :=z-type-after-stat
  #object[clojure.lang.AFunction$1 0x5a1da95f "clojure.lang.AFunction$1@5a1da95f"],
  :=size :com.rpl.specter.impl/NONE,
  :=model-options {:model-type :metamorph.ml/ols},
  :=group :=inferred-group,
  :=y0 :com.rpl.specter.impl/NONE,
  :=mark-size :com.rpl.specter.impl/NONE,
  :=violinmode :com.rpl.specter.impl/NONE,
  :=design-matrix
  #object[clojure.lang.AFunction$1 0x3014f311 "clojure.lang.AFunction$1@3014f311"],
  :=size-type
  #object[clojure.lang.AFunction$1 0x59ae8d24 "clojure.lang.AFunction$1@59ae8d24"],
  :=zmin :com.rpl.specter.impl/NONE,
  :=x-showgrid true,
  :=r :com.rpl.specter.impl/NONE,
  :=color :com.rpl.specter.impl/NONE,
  :=mark-color :com.rpl.specter.impl/NONE,
  :=bar-width :com.rpl.specter.impl/NONE,
  :=y1-after-stat :=y1,
  :=x :x,
  :=symbol :com.rpl.specter.impl/NONE,
  :=x-after-stat :=x,
  :=yaxis-gridcolor "rgb(255,255,255)",
  :=lon :com.rpl.specter.impl/NONE,
  :=text :com.rpl.specter.impl/NONE,
  :=type
  #object[clojure.lang.AFunction$1 0x58d6e395 "clojure.lang.AFunction$1@58d6e395"],
  :=x-type-after-stat
  #object[clojure.lang.AFunction$1 0x6fb6ee81 "clojure.lang.AFunction$1@6fb6ee81"],
  :=traces
  #object[clojure.lang.AFunction$1 0x78ad141d "clojure.lang.AFunction$1@78ad141d"],
  :=x-type
  #object[clojure.lang.AFunction$1 0x55c76181 "clojure.lang.AFunction$1@55c76181"],
  :=histogram-nbins 10,
  :=automargin false,
  :=stat :=dataset,
  :=z :z,
  :=width 500,
  :=lat :com.rpl.specter.impl/NONE,
  :=margin {:t 25},
  :=color-type
  #object[clojure.lang.AFunction$1 0x3ae2eae7 "clojure.lang.AFunction$1@3ae2eae7"],
  :=xaxis-gridcolor "rgb(255,255,255)",
  :=mark :point,
  :=size-range [10 30],
  :=x-title :com.rpl.specter.impl/NONE,
  :=colorscale :com.rpl.specter.impl/NONE,
  :=layout
  #object[clojure.lang.AFunction$1 0x62f8d9dc "clojure.lang.AFunction$1@62f8d9dc"],
  :=colnames
  #object[clojure.lang.AFunction$1 0x72652462 "clojure.lang.AFunction$1@72652462"],
  :=y :y,
  :=x1-after-stat :=x1,
  :=dataset _unnamed [5 2]:

| :x | :y |
|---:|---:|
|  1 |  2 |
|  2 |  4 |
|  3 |  3 |
|  4 |  5 |
|  5 |  7 |
,
  :=background "rgb(235,235,235)",
  :=theta :com.rpl.specter.impl/NONE,
  :=y0-after-stat :=y0,
  :=y-after-stat :=y,
  :=predictors [:=x],
  :=marker-size-key
  #object[clojure.lang.AFunction$1 0x1cc098c6 "clojure.lang.AFunction$1@1cc098c6"],
  :=meanline-visible :com.rpl.specter.impl/NONE},
 :kindly/f #'scicloj.tableplot.v1.plotly/plotly-xform}

You see, what API functions such as plotly/layer-line generate are certain maps called templates, a brilliant concept from the Hanami library.

This is not the resulting Plotly.js specification yet. It is a potential for it, specifying lots of partial intermediate values, called substitution keys. By Tableplot’s convention, substitution keys are keywords beginning with =, such as :=layout or :=mark-color.

Why templates? They separate what you want (data mappings, colors, sizes) from how to render it (the actual Plotly.js specification). This gives you flexibility: you can override specific details or let defaults handle everything.

Substitution keys can have default values, which can also be functions computing them from the values defined by other keys. On the user side, we may override any of these, as we’ll see below.

What if we actually want to see not the template, but the resulting Plotly.js specification? This is what plotly/plot is for.

(-> sample-data
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line
    plotly/plot
    kind/pprint)
{:data
 [{:y [2 4 3 5 7],
   :r nil,
   :name "",
   :marker {:size 20},
   :fill nil,
   :mode :markers,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}
  {:y [2 4 3 5 7],
   :r nil,
   :name "",
   :fill nil,
   :mode :lines,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}],
 :layout
 {:width 500,
  :height 400,
  :margin {:t 25},
  :automargin false,
  :plot_bgcolor "rgb(235,235,235)",
  :xaxis {:gridcolor "rgb(255,255,255)", :title :x, :showgrid true},
  :yaxis {:gridcolor "rgb(255,255,255)", :title :y, :showgrid true},
  :title nil}}

Goal

Assume that we now wish to colour the grid lines: vertical by green, horizontal by red. After all, what would be a better way to teach Tufte’s data-ink ratio principle than doing exactly what it asks us to avoid, by adding some chartjunk?

Here are three approaches, each with different tradeoffs.

Approach 1: Using the relevant substitution keys

Sometimes, what we need can be precisely specified in Tableplot. You may find the following in Tableplot’s Plotly API reference:

To use them, you can add a base before your plot layers, and configure it with these keys. We use plotly/base because its parameters flow to all subsequent layers, which is useful when composing multiple layers with shared settings.

(-> sample-data
    (plotly/base {:=xaxis-gridcolor "green"
                  :=yaxis-gridcolor "red"})
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line)

Let us see what actually has changed in the resulting specification:

(-> sample-data
    (plotly/base {:=xaxis-gridcolor "green"
                  :=yaxis-gridcolor "red"})
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line
    plotly/plot
    kind/pprint)
{:data
 [{:y [2 4 3 5 7],
   :r nil,
   :name "",
   :marker {:size 20},
   :fill nil,
   :mode :markers,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}
  {:y [2 4 3 5 7],
   :r nil,
   :name "",
   :fill nil,
   :mode :lines,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}],
 :layout
 {:width 500,
  :height 400,
  :margin {:t 25},
  :automargin false,
  :plot_bgcolor "rgb(235,235,235)",
  :xaxis {:gridcolor "green", :title :x, :showgrid true},
  :yaxis {:gridcolor "red", :title :y, :showgrid true},
  :title nil}}

Approach 2: Overriding a broader-scope key

What if the specific keys you need don’t exist in Tableplot yet?

Plotly.js itself will always be richer and more flexible than Tableplot’s parameter system.

Imagine that the above :=xaxis-gridcolor & :=yaxis-gridcolor would not be supported.

If you read about Styling and Coloring Axes and the Zero-Line in the Plotly.js docs, you will see that, under the layout part of the specification, you can specify gridcolor for each of the axes.

In Tableplot, we can specify the whole layout using :=layout, and thus have anything we need in there.

  • :=layout - The layout part of the resulting Plotly.js specification
(-> sample-data
    (plotly/base {:=layout {:xaxis {:gridcolor "green"}
                            :yaxis {:gridcolor "red"}}})
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line)

Oh 🙄

Notice that a few other details of the aesthetics have changed, like the plot’s background color.

That is what happens when we override the whole :=layout. It is a powerful option, that you may or may not like, depending on your use case.

Let us see what happens:

(-> sample-data
    (plotly/base {:=layout {:xaxis {:gridcolor "green"}
                            :yaxis {:gridcolor "red"}}})
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line
    plotly/plot
    kind/pprint)
{:data
 [{:y [2 4 3 5 7],
   :r nil,
   :name "",
   :marker {:size 20},
   :fill nil,
   :mode :markers,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}
  {:y [2 4 3 5 7],
   :r nil,
   :name "",
   :fill nil,
   :mode :lines,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}],
 :layout {:xaxis {:gridcolor "green"}, :yaxis {:gridcolor "red"}}}

As expected this time, the layout is small and simple, just what you specified.

By the way, if you read further in that link to the docs, you will see that :=layout depends on :=xaxis-gridcolor and :=yaxis-gridcolor, among other things. When we specified those narrow-scope keys in our previous example, we actually went through affecting the broad-scope key, :=layout.

Approach 3: Direct Manipulation After plotly/plot

The previous approaches work within Tableplot’s API. But what if you need more surgical control — to use Plotly.js concepts while preserving most defaults?

Of course we can do that!

Of course, the answer has been in front of us the whole time: It’s just data.

We do not need to use Tableplot’s API for everything. We can call plotly/plot to realize the actual Plotly.js specification, as data. Then we can keep processing it using the Clojure standard library, which has lovely functions like assoc-in.

(-> sample-data
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line
    plotly/plot
    (assoc-in [:layout :xaxis :gridcolor] "green")
    (assoc-in [:layout :yaxis :gridcolor] "red"))

Let us observe the transformation – before and after the assoc-in:

(-> sample-data
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line
    plotly/plot
    (assoc-in [:layout :xaxis :gridcolor] "green")
    (assoc-in [:layout :yaxis :gridcolor] "red")
    kind/pprint)
{:data
 [{:y [2 4 3 5 7],
   :r nil,
   :name "",
   :marker {:size 20},
   :fill nil,
   :mode :markers,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}
  {:y [2 4 3 5 7],
   :r nil,
   :name "",
   :fill nil,
   :mode :lines,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}],
 :layout
 {:width 500,
  :height 400,
  :margin {:t 25},
  :automargin false,
  :plot_bgcolor "rgb(235,235,235)",
  :xaxis {:gridcolor "green", :title :x, :showgrid true},
  :yaxis {:gridcolor "red", :title :y, :showgrid true},
  :title nil}}
(-> sample-data
    (plotly/layer-point {:=mark-size 20})
    plotly/layer-line
    plotly/plot
    (assoc-in [:layout :xaxis :gridcolor] "green")
    (assoc-in [:layout :yaxis :gridcolor] "red")
    kind/pprint)
{:data
 [{:y [2 4 3 5 7],
   :r nil,
   :name "",
   :marker {:size 20},
   :fill nil,
   :mode :markers,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}
  {:y [2 4 3 5 7],
   :r nil,
   :name "",
   :fill nil,
   :mode :lines,
   :width nil,
   :type "scatter",
   :theta nil,
   :z nil,
   :lon nil,
   :lat nil,
   :x [1 2 3 4 5],
   :text nil}],
 :layout
 {:width 500,
  :height 400,
  :margin {:t 25},
  :automargin false,
  :plot_bgcolor "rgb(235,235,235)",
  :xaxis {:gridcolor "green", :title :x, :showgrid true},
  :yaxis {:gridcolor "red", :title :y, :showgrid true},
  :title nil}}

Summary

Tableplot’s parameter substitution system gives you three levels of control:

  1. Specific substitution keys (:=xaxis-gridcolor, :=yaxis-gridcolor)
    • ✅ Most convenient and discoverable
    • ✅ Preserves all other defaults
    • ❌ Limited to what Tableplot explicitly supports
  2. Broad-scope keys (:=layout)
    • ✅ Full Plotly.js flexibility
    • ✅ Declarative, within Tableplot’s API
    • ❌ Overrides ALL defaults for that scope
  3. Direct data manipulation (assoc-in after plotly/plot)
    • ✅ Complete control
    • ✅ Surgical precision - only change what you want
    • ❌ More verbose
    • ❌ Leaves Tableplot’s template system

The key insight: it’s all just data. Templates with substitution keys give you flexibility without magic. You can always drop down to plain Clojure data manipulation when needed.

For more examples and the complete API reference, see the Tableplot documentation.

source: src/data_visualization/tableplot_parameter_flow.clj