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.

§ 1A 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] },
      ]
    }
  }
}
text
$ 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:

text
$ wcl wdoc build sales.wcl --out _site
wcl::eval::schema_violation

  × block kind 'bar_chart' is not allowed inside 'page'

§ 1.1Sizing

The two size pairs do different jobs, and note that they are spelled differently too: a diagram measures in whole pixels (width = 380), a chart 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, which fits its viewBox to what was actually drawn plus a 10-pixel margin on each side, and then leaves the browser to scale that box into the rendered size — uniformly, preserving the aspect ratio, centring whatever is left over.

So the two pairs decide a scale factor, and the way to control it is to 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 chart is simply drawn at some other size, text included. Equal pairs — a 380 × 220 chart in a 380 × 220 diagram — render at about 92%, which 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, which they will.

§ 2Series 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, 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 — the coercion Types covers. 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] — but 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.

§ 3Bar charts

Add a second series and the bars group. Every category gets one slot, the slot is shared by the series 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.

Bars are drawn 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.

§ 4Line 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 — so the join at each point is really two rounded caps meeting, which the wdoc-line class supplies 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 pointscategory picks the slot and value picks the height, so a ChartPoint can sit exactly on a marker of the series you care about.

§ 5Pie 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: each label is drawn 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. Each is divided 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 that is indistinguishable from a true arc, and it is why the block needs nothing from the renderer that a flowchart box does not.

Give a pie a square box

The radius is fitted 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.

§ 6The 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, five labels, and that count is fixed.

Tick labels are rounded to two decimals, which is what keeps an auto-fitted bound from printing a floating-point tail. Set either bound yourself when the data should be read 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, which is that the data has no variation. Four zeros are the degenerate case: the upper bound would be zero as well, so it is nudged 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 is drawn. Set y_max when a chart has to render even with no data in it.

§ 7Categories and axis labels

categories names the x slots. Omit it and the slots are numbered from 1. The list has one more job than labelling, though, and it is an easy one to be caught by: 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. The extra values are silently dropped.
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 a chart is built from one place and labelled from another, derive both from the same list — again, Charts from document data.

The other three text fields are decoration, and all three are optional. title is centred at the top of the box, x_label sits below the category labels, y_label 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 visibly shorter plot than a bare chart of the same height.

§ 8The 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.

§ 9Colour

No chart emits a fill. Every bar, line, marker and slice carries a classwdoc-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, which makes recolouring a chart 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.

§ 9.1Change 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 and styling.

§ 9.2Redeclare the class

When one series has to be a particular colour, declare the class. A class block at document level is emitted 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: bars and slices are filled, lines and markers are stroked. The dark and light blocks are optional per-mode overrides; without them the outer values are used in both modes. Themes and styling 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, 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, which is how an edge finds it.

§ 10Charts 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 labels and the values are read 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. Do not, at the document's top level: 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.

§ 11Copy 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 primitives. 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 — and the three stdlib charts are built from 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 thirty 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 thirty 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, and it is the subject of Writing your own blocks — which covers the other two lowering interfaces, page content and terminal widgets, and what to do when a block cannot be expressed in WCL at all.

§ 12Field 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

§ 13Where to go next