wdoc · reference

Charts

16 min read · 2026-08-20 · wcl 0.33.2-alpha

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:

sales.wclwcl
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] },
      ]
    }
  }
}
console
$ wcl wdoc build sales.wcl --out _site
wrote 1 page

That page holds this:

Revenue by quarter019.53958.578Q1Q2Q3Q4Quarter$k

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:

console
$ 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.

UnionShapeUsed 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:

wcl
# 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.

Revenue by quarter20252026019.53958.578Q1Q2Q3Q4Quarter$k
wcl
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:

Latency (ms)p50p99011223344MonTueWedThuFriDay12141118132831264430deploy
wcl
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.

Market shareAlphaBetaOther
wcl
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:

wcl
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:

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.

categoriesWhat happens
OmittedThe slot count comes from the first series. Labels are 1, 2, 3, …
Fewer than the valuesThe chart draws that many slots and drops the extra values without a word.
More than the valuesThe 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:

wcl
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:

ClassPaints
wdoc-series-1..8Bars, lines, markers, slices, and legend swatches, cycled by index
wdoc-lineThe line-chart stroke width and its rounded joins (no colour: the series class supplies that)
wdoc-axisThe x baseline and the y spine
wdoc-gridThe horizontal gridlines
wdoc-axis-labelTick values, category labels, x_label, y_label
wdoc-chart-titleThe title
wdoc-legendLegend text, and a pie chart's slice labels
wdoc-point-labelThe values printed by point_labels = true
wdoc-annotationA 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:

sales.wclwcl
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:

wcl
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:

wcl
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:

wcl
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 },
    ]
  }
}
RustWCLShell

Four things in those 34 lines are worth naming, because they are the four you copy every time.

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.

FieldTypeDefaultMeans
serieslist<ChartSeries>requiredOne { name, values } record per series
titleutf8?noneCentred title above the plot
categorieslist<utf8>?1, 2, 3, …x-slot labels, and the slot count
x_labelutf8?noneAxis title below the category labels
y_labelutf8?noneAxis title above the y spine
y_minf64?0.0Lower scale bound
y_maxf64?largest valueUpper scale bound
x / yf640.0Position within the diagram
width / heightf64360.0 / 220.0Drawing size. Match the diagram
ididentifier?noneNames the shape, so id -> other connects it
classlist<utf8>?noneAccepted and ignored. See the warning above
connect_pointslist<AnchorSide>?all four sidesWhich sides an edge attaches to
line_chart onlyTypeDefaultMeans
point_labelsbool?falsePrint every point's value above its marker
pointslist<ChartPoint>?[]Author-pinned annotations

pie_chart takes slices and the shape fields, and nothing else.

FieldTypeDefaultMeans
sliceslist<ChartSlice>requiredOne { label, value } record per slice. Values are relative
titleutf8?noneCentred title above the pie
x / yf640.0Position within the diagram
width / heightf64240.0Drawing size. Keep it square
ididentifier?noneNames the shape for edges
classlist<utf8>?noneAccepted and ignored
connect_pointslist<AnchorSide>?all four sidesWhich sides an edge attaches to

Where to go next