Custom charts¶
When no built-in type draws the shape you need, type: custom lets you draw it
yourself. A custom chart is an SVG template: a Jinja-style
file that turns the chart's query rows into SVG shapes, rendered by the dbt Charts
engine (see The template dialect). The template reads the
board's style, so a custom chart picks up the theme's colors and fonts and
follows a theme switch like any built-in chart.
Try the built-in types and composed charts first. A built-in chart gets axes, legends, hover, sizing rules, and visual warnings for free; a custom chart draws only what its template draws.
For ready-made templates (Sankey, funnel, gauge, radial bar, XmR, isotype, waffle, and radar), see Custom chart examples.
A first custom chart¶
A template can live inline, under svg:. This one draws a Playfair-style
balance chart: two lines with the gap between them filled and labeled, a form
no built-in type draws. Its header declares the channels it reads:
charts:
trade_balance:
type: custom
query:
columns: [year, exports, imports]
values:
- [1700, 60, 40]
- [1710, 75, 50]
- [1720, 80, 55]
- [1730, 110, 60]
- [1740, 125, 80]
- [1750, 150, 95]
- [1760, 170, 115]
- [1770, 200, 130]
svg: |
{#---
description: Two lines with the gap between them filled and labeled.
channels:
x:
description: Column holding the period.
y:
description: The upper line's column, then the lower line's.
multiple: true
---#}
{% set up = channels.y[0] %}{% set lo = channels.y[1] %}
{% set n = rows | length %}{% set pad = 40 %}
{% set ymax = [rows | map(attribute=up) | max, rows | map(attribute=lo) | max] | max * 1.1 %}
<g font-family="{{ style.font.family }}" font-size="{{ style.font.size }}" fill="{{ style.font.color }}">
<polygon fill="{{ style.tones.positive }}" fill-opacity="0.3"
points="{% for r in rows %}{{ pad + loop.index0 * (width - 2 * pad) / (n - 1) }},{{ height - pad - r[up] / ymax * (height - 2 * pad) }} {% endfor %}{% for r in rows | reverse %}{{ pad + (n - 1 - loop.index0) * (width - 2 * pad) / (n - 1) }},{{ height - pad - r[lo] / ymax * (height - 2 * pad) }} {% endfor %}"/>
<polyline fill="none" stroke="{{ style.accent }}" stroke-width="2"
points="{% for r in rows %}{{ pad + loop.index0 * (width - 2 * pad) / (n - 1) }},{{ height - pad - r[up] / ymax * (height - 2 * pad) }} {% endfor %}"/>
<polyline fill="none" stroke="{{ style.muted }}" stroke-width="2"
points="{% for r in rows %}{{ pad + loop.index0 * (width - 2 * pad) / (n - 1) }},{{ height - pad - r[lo] / ymax * (height - 2 * pad) }} {% endfor %}"/>
{% for r in rows %}
<text x="{{ pad + loop.index0 * (width - 2 * pad) / (n - 1) }}" y="{{ height - pad / 3 }}"
text-anchor="middle">{{ r[channels.x] }}</text>
{% endfor %}
{% set mid = rows[n // 2] %}
<text x="{{ pad + (n // 2) * (width - 2 * pad) / (n - 1) }}"
y="{{ height - pad - (mid[up] + mid[lo]) / 2 / ymax * (height - 2 * pad) }}"
text-anchor="middle" dy="0.35em" font-style="italic">Balance in our favor</text>
<text x="{{ width - pad }}" y="{{ height - pad - rows[n - 1][up] / ymax * (height - 2 * pad) - 8 }}"
text-anchor="end" fill="{{ style.accent }}">{{ up }}</text>
<text x="{{ width - pad }}" y="{{ height - pad - rows[n - 1][lo] / ymax * (height - 2 * pad) + 22 }}"
text-anchor="end" fill="{{ style.muted }}">{{ lo }}</text>
</g>
x: year
y: [exports, imports]
rows:
- trade_balance
The template writes SVG content only. dbt Charts draws the title and
subtitle, clips the template's output to its drawing box (width x height),
and places it on the board.
Template files¶
Inline templates suit a one-off chart. To reuse a chart across boards, put the
template in your project's templates/ folder, next to dbt_charts.yml, and
name it with template::
queries: activation_funnel: columns: [stage, users] values: - [Visited site, 48210] - [Started signup, 12940] - [Upgraded, 740] charts: activation: type: custom template: funnel query: activation_funnel title: Activation funnel x: stage y: users rows: - activation
template: funnel reads templates/funnel.svg.j2. A chart sets exactly one of
template: or svg:.
Finding templates¶
dct describe reads a template's header, so you never open the file to learn
what it takes:
dct describe templates/sankey.svg.j2 # one template
dct describe templates/ # every template in the folder
Each template prints its description, its channels (required or optional, and
whether a list is allowed), and its options (type, default, description).
dct describe --json returns the same under a template key. Describing a
board shows, for each custom chart, the template it uses and that template's
description. A template: name with no file lists the templates the project
does have, and an unsupported type: such as sankey names the matching
template when the project has one.
Channels and options¶
A template file declares what it takes in a header: a Jinja comment that opens
with {#--- and closes with ---#}, holding YAML. It has two parts.
Channels are the chart's data columns, named with the same channel fields
built-in charts use: x, y, color, size, shape, theta, and value.
A chart sets them at its root, exactly as it would on a bar or pie chart. Pick
the channel that matches the role: a category along a funnel is x and its
count is y, the way a bar chart reads; a part of a whole is color and
theta, the way a pie reads.
Options are everything else the template can be told: a format, a maximum,
a symbol. A chart sets them under options:.
{#---
description: Funnel of ordered stages, one row per stage in query order.
channels:
x:
description: Column naming each stage.
y:
description: Column holding the count that reached each stage.
options:
value_format:
type: string
default: ",.0f"
label_width:
type: number
default: 120
---#}
type: custom template: funnel x: stage y: users options: label_width: 140
A channel or option without a default is required; default: null makes it
optional with no value, which a template tests with {% if options.target %}.
A channel declared with multiple: true takes a list of columns
(y: [disease, wounds]) and always reaches the template as a list. A template
with no header, an inline one included, takes no channels and no options.
The chart must match the header. A channel or option the header doesn't
declare, a missing required one, or a value of the wrong type is a validation
error, so a typo fails when the board compiles rather than drawing something
wrong. Every channel, and every column option, is also checked against the
query's result columns when the chart renders.
Option type |
Accepts |
|---|---|
column |
The name of a column in the chart's query, for a secondary role such as a gauge's target. |
columns |
A list of column names. |
string |
Any text. |
number |
Any number. |
integer |
A whole number. |
boolean |
true or false. |
color |
A color: hex, a CSS color name, or a palette token. |
What a template sees¶
| Name | What it holds |
|---|---|
rows |
The query result, one mapping per row: {{ r.revenue }} or {{ r[channels.y] }}. A chart with no query: gets no rows. |
columns |
The result's column names, in order. |
channels |
The columns the chart set for each declared channel: channels.x is a column name, or a list for a multiple channel; an optional channel left unset is none. |
options |
The chart's options, with the header's defaults filled in. |
width, height |
The drawing box in pixels: inside the card padding, below the title. See Where the drawing lands. |
uid |
A prefix unique to this chart. Use it for id values (gradients, clip paths, symbols) so two charts on one board never share an id. |
style |
The board's style, resolved. See Style. |
variables |
The board's variable values for this render: variables.region. A query that reads a variable already returns filtered rows; this is for labels and titles the template draws itself. |
math |
math.sin, math.cos, math.tan, math.asin, math.acos, math.atan2, math.sqrt, math.log, math.exp, math.floor, math.ceil, math.pi, math.tau. Angles are in radians. |
And these helpers:
| Helper | Returns |
|---|---|
value \| format(spec) |
The value formatted with a d3-format spec, the same formats format: takes elsewhere: {{ 0.432 \| format('.1%') }} gives 43.2%. |
text_width(text, size, weight) |
The width in pixels of text, a string, in the board's font at size pixels: text_width(r.name, style.font.size). Turn a number into text first, with \| string or \| format(...). weight is optional and defaults to 400. It takes any weight style can carry: a whole number from 1 to 1000 such as 600, or a CSS keyword such as bold or normal, so style.charts.title.font.weight works whatever the theme sets. Use it to fit or wrap labels. |
color_for(value, column) |
The board's pinned color for that column and value, from style.charts.category_colors, so a pinned category matches the built-in charts on the board. A value with no pin gets the next color of the categorical palette, in the order the template asks; the same value always gets the same color within a chart, and running out of palette colors is an error. |
href(row) |
The chart's link: for that row, with every {{ channel }} placeholder filled from the row. See Links. |
raise_error(message) |
Stops the render and shows message in the chart's place. Use it to reject data the template cannot draw. |
Templates use strict variables: a misspelled column, option, or style key is an error that names the template and line, not an empty string.
Query values arrive as the warehouse returns them. Some warehouses return
decimals, so convert before doing arithmetic with floats:
{{ (r.revenue | float) * scale }}.
Style¶
style has the same structure as the style: block you
author on a board or in a theme, after the theme, the board's style:, and
every inherited value are merged. So a key you can set is a key a template can
read, at the same path:
| In a template | What it is |
|---|---|
style.background |
The board background. |
style.font.family, style.font.color, style.font.size |
Body text. |
style.muted, style.accent |
Secondary text and the accent color. |
style.tones.positive, .negative, .warning, .info |
Status colors. |
style.charts.color.categorical.palette |
The categorical palette built-in charts use, as a list of colors. |
style.charts.color.categorical.single_series_palette[0] |
The color of a single-series chart. |
style.charts.axis.grid.color |
Gridlines and tracks. |
style.charts.axis.labels.font |
Axis label text. |
style.charts.title.font |
Chart title text. |
style.palettes.<role> |
Each palette role, resolved: a list of colors for a categorical, sequential, or diverging palette, and a mapping of named stops (solid, bg, border, text, ...) for a tone. |
Every color is a resolved hex value. The one difference from what you author
is palettes:: you name a palette (category: editorial-10), and the
template sees its colors.
Read colors and fonts from style rather than writing them into the
template, and the chart follows every theme, dark ones included, without
changes. The examples never hard-code a color.
To pin a category's color across every chart on a board, built-in or custom,
use style.charts.category_colors and read it in the template with
color_for(value, column):
style: charts: category_colors: qualification: values: Graduates: "#2b59a8" Certificated: "#d33a32"
Links¶
A custom chart takes link: like every other chart. A link with no channel
placeholder wraps the whole drawing in one anchor, so the chart is clickable
with no change to the template:
type: custom template: funnel link: /boards/activation
A link with placeholders is per row: link: /boards/stage/{{ x }} fills
{{ x }} from the column the x channel binds, and the template decides
which marks carry it by wrapping them in an anchor:
A per-row link is drawn only where the template calls href, so a template
that never calls it with such a link fails the chart rather than quietly
dropping the link. A board path resolves against the host like any chart
link; http, https and mailto URLs pass through; any other scheme is
refused.
Fonts¶
style.font.family is the board's font stack, and style.charts.title.font,
style.charts.axis.labels.font and the other font blocks carry the family,
size, weight and color the built-in charts use. A template that writes
font-family="{{ style.font.family }}" paints the board font, and an export
embeds that font the way it does for every other chart. text_width measures
in the same font, at the weight you pass it.
Where the drawing lands¶
The engine draws the title and subtitle, then places the template's output
in a group translated below them, inside the card's padding. width and
height are that box, so a template draws from (0, 0) to (width, height)
and never allows for the title or the padding itself. Anything drawn outside
that box is clipped, so it cannot paint over the title. A chart with no title:
hands the template the full card height.
What a template may draw¶
A template's output must be well-formed SVG built from drawing elements:
shapes (rect, circle, ellipse, line, polyline, polygon, path),
text (text, tspan, textPath), grouping (g, defs, symbol, use),
paint (linearGradient, radialGradient, stop, pattern, clipPath,
mask, marker), title and desc, and a with an href the
link rules accept.
The output is checked before it reaches the board, and anything outside that set is an error that names the element or attribute. Nothing is silently removed. Not allowed:
<script>,<foreignObject>,<style>,<image>, and animation elements such as<animate>and<set>- filters (shadow, blur) and raster images: neither fits the sandboxed,
vector-only output, so
<filter>is refused along with<image> - event attributes such as
onclick - the board's own hooks:
role,aria-*anddata-*attributes, and class names starting withrole-ordbt-, which the page's runtimes read - a paint attribute (
fill,stroke,stop-color, ...) that is not a color,none,currentColororurl(#id) - elements nested more than 64 levels deep
- references out of the page: outside
<a>,hrefmust be a#idreference (href="#{{ uid }}-person"), andurl(...)must beurl(#id)(fill="url(#{{ uid }}-fade)") - comments, processing instructions, and CDATA sections
- CSS escapes (a backslash in an attribute) and CSS functions other than
url(#id)that load a resource, such asimage-set()
Values written with {{ }} are escaped, so a label containing < or & draws
as text.
A <title> inside a shape becomes the browser's native tooltip: delayed,
unstyled, and absent on touch and in PNG or PDF exports. It is not the board's
tooltip card.
The template dialect¶
The engine renders templates with minijinja,
a Jinja implementation with no reflection into the host, so a template can
reach only the names listed above. Blocks, loops, macros, namespace(),
dict() with ** merging, and the common filters all work as in Jinja. The
difference is that values have filters, not Python methods:
| Instead of | Write |
|---|---|
text.split(",") |
text \| split(",") |
d.get(k, 0) |
d[k] if k in d else 0 |
d.keys(), d.values() |
d \| items \| map(attribute=0), ... \| map(attribute=1) |
x \| round(0, "ceil") |
math.ceil(x) |
"%d" % n |
n \| format('d') |
A syntax the engine does not accept fails when the board compiles, naming the
line. Two budgets bound a template, and crossing either fails the chart, never
the board: a runaway loop ends on an instruction budget, and the output is
capped by the project's execution.max_template_output_bytes.
Writing good templates¶
Let the query do the math. Jinja is good at loops and text, and slow and awkward at algorithms. Sorting, ranking, running totals, shares, and binning belong in SQL. The XmR example computes its limits in the template only because they are a few sums; a layout that needs iteration is a sign the shape should be a built-in type.
Validate the data. Check what the template depends on and stop with
raise_error when it is wrong: a gauge with more than one row, a Sankey whose
flows loop back, a negative value in a waffle.
Use uid for every id. A board renders many charts into one document, so
an id like fade would collide when a template is used twice.
Leave room for text. Measure labels with text_width instead of guessing,
and keep a fixed margin for labels that sit outside the marks.
Errors¶
| Code | When |
|---|---|
ERR-CUSTOM-TEMPLATE |
The template file is missing, its header is not valid YAML, or the template has a syntax error. |
ERR-CUSTOM-FIELDS |
A channel or option the header doesn't declare, a missing required one, or a value of the wrong type. |
ERR-CUSTOM-RENDER |
The template failed while drawing: a missing column, an undefined name, or a raise_error call. |
ERR-CUSTOM-SVG |
The output is not well-formed SVG, or uses an element or attribute that is not allowed. |