Charts
layout: chart reads all of its children, fixes one shared scale from their
data, then draws — bars, lines, areas, dots and bubbles on one plane, and the
part-to-whole layout: pie.
Categories and one series
|chart| "Deploys per weekday" { categories: "Mon", "Tue", "Wed", "Thu", "Fri"; } [
|bars| { data: 4, 9, 6, 12, 7; }
]
categories: names the x axis and data: gives one value per category — the
two counts must match, and a mismatch is an error rather than a short bar.
Nothing here says how anything should look. The chart’s label became the title, the value axis fitted itself to the numbers, and the colour came off the palette.
More than one series
|chart| "Revenue by region ($k)" {
categories: "Q1", "Q2", "Q3", "Q4"; bars: stacked;
} [
|bars| "NA" { data: 40, 45, 50, 60; }
|bars| "EU" { data: 30, 35, 33, 40; }
|bars| "APAC" { data: 20, 25, 30, 38; }
]
Every series is a child node, and its label is its legend entry — the legend appears on its own the moment there are two.
bars: on the chart decides how the bar series combine:
grouped(the default) — side by side.stacked— piled into a total, as here.overlay— drawn over one another.
Series walk the palette in order, so three series are three distinct hues with no colour declared.
Lines, axes and a reference mark
|chart| "Latency (ms)" { categories: "p50", "p90", "p99"; } [
|axis#ms| { side: left; }
|mark| "budget" { at: 250; axis: ms; stroke: --red-deep; stroke-style: dashed; }
|line| "before" { data: 40, 180, 420; curve: smooth; marker: circle; }
|line| "after" { data: 30, 120, 260; curve: smooth; marker: circle; }
]
|line| draws one shape through its data. curve: smooth interpolates without
overshooting any point, and marker: puts a mark on every datum.
Write an |axis| only when you have something to say about it — here, only to
carry an id so the |mark| can anchor to it.
A mark is placed in data space, not pixels. It tracks the scale, and
survives a direction: row flip unchanged.
A formula, and a band across it
|chart| "Edge requests per second" { samples: 96; } [
|axis#rps| { side: left; range: 0 900; }
|axis#hour| "Hour of day (UTC)" { side: bottom; range: 0 24; step: 4; }
|area| { fn: (70 + 620 * exp(-((x - 10.5)^2) / 7) + 470 * exp(-((x - 19)^2) / 11)); }
|band| "over ceiling" { range: 9.5 11.5; axis: hour; fill: --amber; }
|mark| "autoscale ceiling" { at: 600; axis: rps; stroke: --red-deep; stroke-style: dashed; }
]
A series may compute its values instead of listing them. fn: is an
expression in x, sampled samples: times across the domain, with the whole
expression engine available — locals, the ternary, and any
function the stylesheet binds. Two Gaussians make the morning and evening
peaks here.
range: on an axis is the visible window, and it is doing two different jobs
above:
- On the x axis it is the formula’s domain — what
fn:sweeps. - On the value axis it leaves headroom above the mark.
Points, labels and hover
|chart| "Score vs. thinking budget" { width: 460; height: 280; } [
|axis| "tokens per task (k)" { side: bottom; }
|axis| "eval score (%)" { side: left; range: 40 90; }
|line| "GLM-5.2" { data: 9 52, 16 63, 27 72, 48 79, 88 83; labels: "off", "low", "mid", "high", "max"; curve: smooth; marker: circle; tooltip: always; }
|dots| "GLM-5.1" { data: 5 47, 12 54, 20 58, 29 62, 40 65, 54 68, 68 69, 86 71; }
]
x y pairs make a numeric x axis — a scatter, rather than the categories of
the first two figures.
labels: is the per-datum text, one entry per point. Where a label fits it
sits on the plot; where it does not it falls to a hover card, and
tooltip: always forces every one inline for export. Every marked point also
carries a native <title>, so the figure is readable by hover in any viewer.
Left to fit itself, the value axis would start at zero and crush these scores
into the top strip. range: 40 90 is the window that actually matters.
Dates and calendar ticks
|chart| "p95 latency, eu-west (ms)" { width: 560; } [
|axis#t| { side: bottom; step: 2 week; }
|band| "incident" { range: "2026-01-24" "2026-02-14"; axis: t; fill: --amber; }
|mark| "rollback" { at: "2026-02-09"; axis: t; stroke: --red-deep; stroke-style: dashed; }
|line| { data: "2026-01-05" 240, "2026-01-12" 232, "2026-01-19" 251, "2026-01-26" 384, "2026-02-02" 412, "2026-02-09" 301, "2026-02-16" 262, "2026-02-23" 244, "2026-03-02" 231, "2026-03-09" 225, "2026-03-16" 219; curve: smooth; marker: dot; }
]
A point’s x may be a quoted ISO date, and the dates are what make the axis a
time axis — there is no scale: to write.
Ticks are then calendar-aware: the span picks the unit, the ticks land on real
boundaries, and the label follows that unit. step: overrides it with a
calendar interval — 2 week here, or month — and a plain number is an error.
Everything else measured on that axis is written in the same literals. The
|band| shading the incident and the |mark| on the rollback both take dates,
because a dated domain is a domain like any other — the other kind is an error
either way round. All the arithmetic is UTC, so the figure renders the same in
every timezone.
Bubbles, and a third number
|chart| "Cost by service ($k / month)" { width: 460; height: 300; } [
|axis| "requests/s" { side: bottom; }
|axis| "p99 latency (ms)" { side: left; }
|bubble| "billing" { at: 300 60; value: 4; }
|bubble| "auth" { at: 1200 45; value: 7; }
|bubble| "cart" { at: 900 105; value: 9; }
|bubble| "media" { at: 2100 155; value: 40; }
|bubble| "search" { at: 3400 215; value: 26; }
|bubble| "feed" { at: 4200 95; value: 17; }
]
A |bubble| is one mark per node rather than a series of data: at: places it
on the plane, and value: sizes it.
The scale is by area, so four times the cost is twice the radius. That is what lets a bubble carry a third number without a third axis.
The label rides inside the bubble where it fits and beside it where it does not — and each bubble, being its own node, takes the next hue in the palette walk.
Radar
|chart| "Profiles" { direction: radial; categories: "Speed", "Range", "Armour", "Cost", "Stealth"; } [
|axis| { range: 0 5; }
|line| "Scout" { data: 5, 4, 2, 3, 5; }
|area| "Cruiser" { data: 3, 3, 5, 4, 2; fill: --purple; }
]
direction: radial bends the category axis into a ring — one spoke per
category — and turns the value axis into the radius. A |line| then closes
into a polygon, and an |area| fills one.
The data is untouched by that flip, which is the rule for every direction: the plane is projected differently, never re-authored.
Part to whole
|pie| "Spend by channel" { hole: 0.5; } [
|slice| "Ads" { value: 40; }
|slice| "SEO" { value: 25; }
|slice| "Email" { value: 20; }
|slice| "Direct" { value: 15; }
]
layout: pie turns each |slice|’s value: into an angle; the values need no
normalising, since the whole is their sum. hole: is the inner-radius fraction,
so 0.5 is a donut and the default 0 a full pie.
Go deeper
The full chart reference covers log and reversed
scales, per-datum paint, segmented formulas, format: and the tooltip rules.