wdoc · reference
Charts
wdoc ships three chart kinds: bar_chart, line_chart, and pie_chart. Each one is a shape, not a page block, so it lives inside a diagram beside the boxes and arrows. Each one takes its data as a list of records. And each one is written entirely in WCL. The whole family is about 460 lines of crates/wcl_wdoc/lib/charts.wcl, and no line of Rust knows that a bar chart exists.
That last fact is the point of this chapter. The three charts are not a plotting library that wdoc happens to bundle. They are three worked examples of the extension mechanism, shipped in the standard library because most documents want them. When the third one does not fit what you are drawing, you copy it and write a fourth. The last section does exactly that.
Every chart on this page is rendered by the build that produced the page, and every command below was run before it was written down.
A chart is a shape in a diagram
Start with one series over four quarters. Save this as sales.wcl:
import <wdoc.wcl>
site sales {
title = "Sales"
toc { chapter "Quarterly revenue" { page = revenue } }
}
page revenue {
title = "Quarterly revenue"
h1 "Quarterly revenue"
diagram { width = 400 height = 240
bar_chart { width = 380.0 height = 220.0
title = "Revenue by quarter"
x_label = "Quarter"
y_label = "$k"
categories = ["Q1", "Q2", "Q3", "Q4"]
series = [
{ name: "2025", values: [42.0, 55.0, 61.0, 78.0] },
]
}
}
}
$ wcl wdoc build sales.wcl --out _site
wrote 1 page
That page holds this:
Read the nesting from the outside in. diagram is the drawing surface. The diagram canvas covers it in full. bar_chart is one shape on that surface, placed by x and y like any other shape, and sized by width and height. Nothing else on the page had to change: a chart goes wherever a flowchart box goes.
Move it out of the diagram and the build stops. A chart is not a page block:
$ wcl wdoc build sales.wcl --out _site
wcl::eval::schema_violation
× block kind 'bar_chart' is not allowed inside 'page'
Sizing
The two size pairs do different jobs, and they are spelled differently too: a diagram measures in whole pixels (width = 380), and a chart measures in f64 (width = 380.0). The diagram's pair is the rendered size of the <svg>. The chart's pair is the coordinate space it draws in. Between them sits the canvas. It fits its viewBox to what was drawn, plus a 10-pixel margin on each side, then leaves the browser to scale that box into the rendered size. The browser scales uniformly, keeps the aspect ratio, and centres whatever is left over.
So the two pairs decide a scale factor. To control it, give the diagram 20 pixels more than the chart in each dimension, the margin. The example above is a 380 × 220 chart in a 400 × 240 diagram, which is a viewBox of exactly 400 × 240 and a scale of exactly 1: the 10-point labels are 10 points on the page.
Nothing goes wrong when the numbers disagree. The canvas draws the chart at some other size, text included. Equal pairs, a 380 × 220 chart in a 380 × 220 diagram, render at about 92%, and no reader will notice. A 240-wide pie in a 600 × 300 diagram renders at 115% and floats in the middle of a mostly empty canvas. That, a reader will notice.
Series and slices
Chart data is a list of records, and there are exactly three record shapes in the family.
| Union | Shape | Used by |
|---|---|---|
| ChartSeries | { name: utf8, values: list<f64> } | bar_chart and line_chart, one entry per series |
| ChartSlice | { label: utf8, value: f64 } | pie_chart, one entry per slice |
| ChartPoint | { label: utf8, category: i64, value: f64 } | line_chart points, one author-pinned annotation |
Each is a single-variant union, and you write the bare record. WCL matches { name: …, values: … } against the declared type by shape and builds the variant for you. Types covers that coercion. The long form still works and changes nothing:
# These two are the same value.
series = [ { name: "2025", values: [42.0, 55.0] } ]
series = [ ChartSeries::Of { name: "2025", values: [42.0, 55.0] } ]
Values are f64. An integer literal promotes, so values: [42, 55] builds and plots the same bars as [42.0, 55.0]. Write the decimal point anyway. It says the value is a measurement rather than a count, and it keeps the list readable next to a category: 3, which really is an integer.
Nothing about the list has to be literal. series is an ordinary field, so any expression of the right shape works. See Charts from document data below.
Bar charts
Add a second series and the bars group. Every category gets one slot, the series share that slot in order, and the bars fill 80% of it. The remaining fifth is the gap between groups.
diagram { width = 400 height = 240
bar_chart { width = 380.0 height = 220.0
title = "Revenue by quarter"
x_label = "Quarter"
y_label = "$k"
categories = ["Q1", "Q2", "Q3", "Q4"]
series = [
{ name: "2025", values: [42.0, 55.0, 61.0, 78.0] },
{ name: "2026", values: [30.0, 48.0, 52.0, 66.0] },
]
}
}
The second series brought the legend with it, and it is the only thing that did. See The legend.
The chart draws bars from the bottom of the plot area, not from a zero line. With the default scale the two are the same thing, because the default lower bound is zero. They part company the moment you set y_min, and they part company badly if a value is negative. See The scale.
Line charts
A line_chart takes the same series and the same categories. It plots each series as connected segments with a marker at every point, and it adds two fields of its own.
point_labels = true prints every point's value above its marker. points pins your own annotations: a ChartPoint is a label, a category (the 0-based x slot), and a value (the height, in data units). An annotation belongs to the chart rather than to a series, so it may sit anywhere on the scale:
diagram { width = 420 height = 250
line_chart { width = 400.0 height = 230.0
title = "Latency (ms)"
x_label = "Day"
categories = ["Mon", "Tue", "Wed", "Thu", "Fri"]
point_labels = true
series = [
{ name: "p50", values: [12.0, 14.0, 11.0, 18.0, 13.0] },
{ name: "p99", values: [28.0, 31.0, 26.0, 44.0, 30.0] },
]
points = [ { label: "deploy", category: 3, value: 44.0 } ]
}
}
Three details of the drawing are worth knowing, because they are what you inherit if you copy the block. A line is a run of separate line segments, not one path. The join at each point is really two rounded caps meeting, and the wdoc-line class supplies those caps rather than the geometry. A marker is a circle of radius 2.6 at every point of every series, the ends included. And point_labels labels every point of every series: on the chart above that is ten labels. Use it on small charts, and use points when you want one thing called out.
point_labels is per chart, points is per annotation
point_labels is a bool on the chart, not a field of a series: there is no way to label one series and leave the other bare. If that is what you want, pin the labels yourself with points. category picks the slot and value picks the height, so a ChartPoint can sit exactly on a marker of the series you care about.
Pie charts
A pie_chart takes slices instead of series, one { label, value } record each, and it has no axes, no categories, and no scale. It has no legend either: the chart draws each label inside its own slice.
diagram { width = 260 height = 260
pie_chart { width = 240.0 height = 240.0
title = "Market share"
slices = [
{ label: "Alpha", value: 42.0 },
{ label: "Beta", value: 31.0 },
{ label: "Other", value: 27.0 },
]
}
}
Values are relative. The chart divides each by the total, so they need not sum to 100, or to anything in particular: [3.0, 1.0] draws three quarters and one quarter as surely as [75.0, 25.0] does. Slices start at twelve o'clock and sweep clockwise in the order you wrote them.
Each arc is a polygon: the centre point, then 49 points sampled along the edge. At the size a document renders, the polygon is indistinguishable from a true arc, so the block needs nothing from the renderer that a flowchart box does not.
Give a pie a square box
The lowering fits the radius to the smaller of the width and the remaining height, so a wide, short pie_chart is a small circle in a wide, mostly empty box rather than an ellipse. The default is 240 × 240. A title takes 22 pixels off the height, and the circle moves down and shrinks to suit.
The scale
Bar and line charts share one scale, and it fits itself to the data. The lower bound y_min defaults to 0. The upper bound y_max defaults to the largest value across every series, so two series always share one axis and stay comparable. Between the two the chart draws four divisions: five gridlines and five labels. That count is fixed.
The chart rounds tick labels to two decimals, so an auto-fitted bound never prints a floating-point tail. Set either bound yourself when the reader has to judge the data against a fixed range rather than against itself:
line_chart { width = 380.0 height = 220.0
y_min = 0.0
y_max = 100.0 # a percentage is always read against 100
categories = ["Mon", "Tue", "Wed"]
series = [ { name: "coverage", values: [81.0, 84.0, 83.0] } ]
}
Two things the scale does not do:
- It does not clip. A value above y_max lands above the plot area, off the top of the canvas. Nothing warns you. The line leaves. If a chart looks empty, check the bounds against the data before you check anything else.
- It does not handle negative values on its own. With the default y_min = 0, a negative bar gets a negative height. Negative height is not valid SVG, so the bar renders as nothing at all. Set y_min below your lowest value and the bars come back, measured from the bottom of the plot area, because that is where a bar starts. There is no zero line in the drawing.
A flat series is safe, and it is worth knowing what it looks like. Four sevens auto-fit to an upper bound of 7, so all four bars are full height. The chart is telling you the truth: the data has no variation. Four zeros are the degenerate case: the upper bound would be zero as well, so the chart nudges it a fraction above the lower one to keep the scale from dividing by itself. Every tick then reads 0, the bars are zero high, and nothing appears. Set y_max when a chart has to render even with no data in it.
Categories and axis labels
categories names the x slots. Omit it and the chart numbers the slots from 1. The list has one more job than labelling, and this one catches people: the number of categories decides how many points the chart draws.
| categories | What happens |
|---|---|
| Omitted | The slot count comes from the first series. Labels are 1, 2, 3, … |
| Fewer than the values | The chart draws that many slots and drops the extra values without a word. |
| More than the values | The build fails: 'at': at: index 2 out of bounds |
The failure is the friendlier of the two. A list that is one label short costs you a bar and says nothing, so when you build a chart from one place and label it from another, derive both from the same list. Again, see Charts from document data.
The other three text fields are decoration, and all three are optional. title sits centred at the top of the box, x_label sits below the category labels, and y_label sits above the y spine. Each one that is present takes vertical room from the plot area, so a chart carrying a title, a legend, and an x-axis label has a shorter plot than a bare chart of the same height.
The legend
There is one rule: a bar or line chart draws a legend when it has more than one series, and does not when it has one. It is not a field, and there is nothing to switch off. A single-series chart says what it is in the title. A two-series chart has to say which is which.
The legend is a row of swatches across the top of the plot, one per series, each carrying that series' palette class and its name. A pie chart has no legend at all, and needs none: its slice labels are the legend.
Colour
No chart emits a fill. Every bar, line, marker, and slice carries a class: wdoc-series-1 through wdoc-series-8, assigned by index and cycled, so a ninth series is wdoc-series-1 again. All the colour is CSS, so recolouring a chart is the same job as restyling anything else on the site.
There are two levers, and you want the first one more often than you expect.
Change the theme
The eight palette classes are wired to the theme's hue variables: series 1 is the theme's blue, 2 its green, 3 yellow, 4 red, 5 purple, 6 cyan, 7 orange, 8 pink. Change the site's theme and every chart in the document moves with it, in both light and dark mode, along with the diagrams and the code blocks. See Themes.
Redeclare the class
When one series has to be a particular colour, declare the class. wdoc emits a document-level class block after the bundled default and after the theme's rule, so yours wins by cascade. You are not fighting the theme, you are the last word in it:
class "wdoc-series-1" {
fill = "#c94f7c"
stroke = "#c94f7c"
dark { fill = "#e08bab" stroke = "#e08bab" }
light { fill = "#a83a63" stroke = "#a83a63" }
}
Set both fill and stroke, and set them on the same class: the renderer fills bars and slices, and strokes lines and markers. The dark and light blocks are optional per-mode overrides. Without them, the outer values apply in both modes. Styling rules covers the class block in full.
The rest of the chart paints with currentColor, so it follows the page's text colour with no configuration at all. Each part has its own class, should you want to reach it:
| Class | Paints |
|---|---|
| wdoc-series-1..8 | Bars, lines, markers, slices, and legend swatches, cycled by index |
| wdoc-line | The line-chart stroke width and its rounded joins (no colour: the series class supplies that) |
| wdoc-axis | The x baseline and the y spine |
| wdoc-grid | The horizontal gridlines |
| wdoc-axis-label | Tick values, category labels, x_label, y_label |
| wdoc-chart-title | The title |
| wdoc-legend | Legend text, and a pie chart's slice labels |
| wdoc-point-label | The values printed by point_labels = true |
| wdoc-annotation | A points marker and its label |
A chart's own class field paints nothing
All three charts declare class: list<utf8>?, as the stdlib's other shapes do. The chart lowerings ignore it: they never put it on anything they draw, so bar_chart { class = ["fancy"] … } builds and has no effect whatever. Colour a chart through the series classes above. The id field, in contrast, does work. It names the shape so an edge can find it.
Charts from document data
Every example so far wrote the numbers into the chart. That is right for a chart that illustrates a point, and wrong for one that reports a fact. A document that carries the data as data can chart it, list it, and total it without ever writing it twice.
series, categories, and slices are ordinary fields holding ordinary expressions, so a gather field and map are the whole technique:
import <wdoc.wcl>
@block("month")
type Month {
@inline(0) name: utf8
revenue: f64
cost: f64
}
@document
type Sales {
@children("month") months: list<Month>
}
month "Jan" { revenue = 42.0 cost = 30.0 }
month "Feb" { revenue = 55.0 cost = 34.0 }
month "Mar" { revenue = 61.0 cost = 39.0 }
month "Apr" { revenue = 78.0 cost = 41.0 }
site sales {
title = "Sales"
toc { chapter "Sales" { page = revenue } }
}
page revenue {
title = "Sales"
h1 "Sales"
diagram { width = 400 height = 240
bar_chart { width = 380.0 height = 220.0
title = "Revenue and cost"
y_label = "$k"
categories = map(months, fn(m: Month) -> utf8 m.name)
series = [
{ name: "revenue", values: map(months, fn(m: Month) -> f64 m.revenue) },
{ name: "cost", values: map(months, fn(m: Month) -> f64 m.cost) },
]
}
}
}
The chart now says what the document says. Add a fifth month block and a fifth pair of bars appears, correctly labelled, because the chart reads the labels and the values from the same list. The mismatch Categories and axis labels warns about cannot happen here. Data views covers the rest of the technique: repeaters, components, and the table that usually belongs beside the chart.
A top-level table does not reach a gather field
Four months are four month blocks above, and it is tempting to write them as a pipe table instead. At the document's top level, do not: the rows parse, but they never reach the root gather field, so months evaluates to [] and the chart draws an empty axis with no error at all. A table inside a block works normally. See Documents, fields and blocks.
Copy one to build your own
The three charts have nothing you do not have. Open crates/wcl_wdoc/lib/charts.wcl and the whole family is there, in the language this book documents: some let bindings that do arithmetic, three @block types, and one lower function each that returns a list of SVG shapes. There is no Rust chart code to extend and no plugin to write. A fourth chart is a fourth block type, and it plugs in exactly the way the first three do.
The contract is two lines long:
interface SvgBlock {
id: identifier?
lower: fn(&SvgBlock) -> list<Svg>?
}
Extend SvgBlock and a diagram accepts your block as a child, because its @children(SvgBlock) slot admits the interface rather than a list of kinds. Return list<Svg> from lower and the renderer draws it. The Svg union has seven members: Rect, Circle, Line, Label, Polygon, Polyline, and Link. The three stdlib charts use the first five.
Here is a whole chart the standard library does not have: a horizontal bar chart, one row per bar, the longest value taking the full width. It is 34 lines, and this page declares them above its own page block:
union HBarRow { Of { label: utf8 value: f64 } }
@block("hbar_chart")
type HBarChart extends SvgBlock {
x = 0.0
y = 0.0
width = 320.0
height = 140.0
id: identifier?
rows: list<HBarRow>
lower = fn(c: HBarChart) -> list<Svg> {
let n = len(c.rows);
let vmax = fold(map(c.rows, fn(r: HBarRow) -> f64 r.value), 0.000001,
fn(a: f64, v: f64) -> f64 max(a, v));
let rh = c.height / n;
flatten(map(range(0, n), fn(i: i64) -> list<Svg> {
let r = at(c.rows, i);
let ry = c.y + rh * i;
[
Svg::Label {
content: r.label, x: c.x + 30.0, y: ry + rh / 2.0,
font_size: 10.0, fit_width: 56.0, fit_height: 12.0,
class: ["wdoc-axis-label"],
},
Svg::Rect {
x: c.x + 64.0, y: ry + rh * 0.15,
width: (c.width - 64.0) * r.value / vmax, height: rh * 0.7,
class: [chart_series_class(i)],
},
]
}))
}
}
It is used like any other chart, and it renders like one:
diagram { width = 340 height = 160
hbar_chart { width = 320.0 height = 140.0
rows = [
{ label: "Rust", value: 61.0 },
{ label: "WCL", value: 24.0 },
{ label: "Shell", value: 9.0 },
]
}
}
Four things in those 34 lines are worth naming, because they are the four you copy every time.
- The data shape is yours. HBarRow is a single-variant union declared beside the block, exactly as ChartSeries is declared beside bar_chart. The bare { label: …, value: … } records in the document coerce to it by shape, with no work on your part.
- x, y, width, and height carry defaults, written width = 320.0 rather than width: f64. A default makes the field optional and lets a :grid or :layered diagram place the shape through a wrapper transform instead of failing on a missing coordinate.
- The geometry is arithmetic inside a map. fold finds the largest value, range(0, n) walks the rows, and flatten turns a list of small lists into the flat list lower has to return. Builtins is the catalogue. The stdlib charts use nothing that is not in it.
- The palette is shared. chart_series_class(i) is a let binding in charts.wcl, and it resolves by name in your document, so your chart cycles the same eight classes and answers to the same theme as the built-in ones. The shared helper is what makes a new chart look like it belongs.
Charts have no privileges here. The same interface is behind every shape in Flowcharts and swimlanes and behind every custom node in a diagram of your own. Writing your own blocks takes it up in full: the other two lowering interfaces, page content and terminal widgets, and what to do when a block cannot be expressed in WCL at all.
Field reference
bar_chart and line_chart share every field below. line_chart then adds two.
| Field | Type | Default | Means |
|---|---|---|---|
| series | list<ChartSeries> | required | One { name, values } record per series |
| title | utf8? | none | Centred title above the plot |
| categories | list<utf8>? | 1, 2, 3, … | x-slot labels, and the slot count |
| x_label | utf8? | none | Axis title below the category labels |
| y_label | utf8? | none | Axis title above the y spine |
| y_min | f64? | 0.0 | Lower scale bound |
| y_max | f64? | largest value | Upper scale bound |
| x / y | f64 | 0.0 | Position within the diagram |
| width / height | f64 | 360.0 / 220.0 | Drawing size. Match the diagram |
| id | identifier? | none | Names the shape, so id -> other connects it |
| class | list<utf8>? | none | Accepted and ignored. See the warning above |
| connect_points | list<AnchorSide>? | all four sides | Which sides an edge attaches to |
| line_chart only | Type | Default | Means |
|---|---|---|---|
| point_labels | bool? | false | Print every point's value above its marker |
| points | list<ChartPoint>? | [] | Author-pinned annotations |
pie_chart takes slices and the shape fields, and nothing else.
| Field | Type | Default | Means |
|---|---|---|---|
| slices | list<ChartSlice> | required | One { label, value } record per slice. Values are relative |
| title | utf8? | none | Centred title above the pie |
| x / y | f64 | 0.0 | Position within the diagram |
| width / height | f64 | 240.0 | Drawing size. Keep it square |
| id | identifier? | none | Names the shape for edges |
| class | list<utf8>? | none | Accepted and ignored |
| connect_points | list<AnchorSide>? | all four sides | Which sides an edge attaches to |
Where to go next
- The diagram canvas. The surface a chart sits on: sizing, layout modes, pan and zoom.
- Connections and routing. A chart has an id, so an arrow can point at it.
- Themes. The hue ring series 1 to 8 cycles through, and light and dark mode.
- Styling rules. The class block a series override is written as, and its dark {} / light {} children.
- Data views. Repeaters and components, for the table that belongs beside the chart.
- Timelines and dopesheets. The other stdlib blocks that cycle the same series palette.
- Writing your own blocks. The fourth chart, and the two lowering interfaces this chapter did not use.