14. Charts
A chart is a layout — layout: chart and layout: pie — so the cascade, paint roles,
the "string" rule, the expression engine, lower-to-primitives, theming, and baking all
apply unchanged (SPEC 11). A chart’s one new job over row/grid is
to read all children first, fix a shared scale (data domain → plot pixels), sample
any formulas, then lower each child to primitives at baked pixel coordinates — the chart
analogue of a grid sizing tracks from its children. Charts add no grammar: the new
surface is type names, properties, and the layout algorithms.
14.1 The chart plane
| Layout | Template | Encodes | Children |
|---|---|---|---|
layout: chart | |chart| | an x/value plane (cartesian or radial) | series, |axis|, |band|, |mark|, |bubble| |
layout: pie | |pie| | part-to-whole, value → angle | |slice| |
width / height set the whole chart (plot plus axis gutters and legend); the plot
area is the remainder after labels are measured. Unset, the plane takes its baked size
(SPEC 10.5) — a chart cannot size to its content
(the content depends on the scale, which depends on the size), so a constant
stands in for auto. fill is the chart background, stroke
its frame, and the cascade styles a chart like any box.
Chart-level properties (on the |chart| / |pie| node):
| Property | Layout | Value | Default |
|---|---|---|---|
direction | chart | column · row · radial | column |
bars | chart | grouped · stacked · overlay | grouped |
categories | chart | quoted-string list — the x-axis (or spoke) labels | indices 1…N |
samples | chart | integer — fn: sample count | 24 |
hole | pie | 0 ≤ n < 1 — inner-radius fraction (a donut) | 0 |
legend | both | top · right · bottom · none ⌛ (SPEC 24) — writing it is an error until the reader lands | auto (shown when ≥ 2 entries) — built |
tooltip | both | none · hover · auto · always (14.8) | auto |
gap | both | number — clear space between the plot and the title / legend outside it | 15 |
clearance | both | number — the daylight every chrome text keeps off what it labels (14.6) | 6 |
categories sets the x (domain) axis’s tick labels — the one form today;
explicit per-axis tick text is deferred (SPEC 24).
14.2 Series
A series is a child node; its smart label is its legend entry (no label → no entry).
Each series lowers to primitives and is valid only inside its layout (a series elsewhere is
an error, like cell: off a grid):
| Series | Layout | Draws | Lowers to | Paint |
|---|---|---|---|---|
|line| | chart | a polyline through the data (a closed loop when radial) | |line| / |path| | stroke, stroke-width, stroke-style |
|area| | chart | a line filled to a baseline | |poly| / |path| + |line| | fill, stroke, baseline |
|bars| | chart | one bar per datum (a wedge when radial) | one |rect| / |poly| each | fill, stroke, radius |
|dots| | chart | one marker per datum | one |oval| / marker each | fill, stroke, marker |
|bubble| | chart | one bubble at a point, sized by value: | one |oval| | fill, stroke |
|slice| | pie | one wedge | one |path| | fill, stroke |
Singular vs. plural is the cardinality: |line| / |area| are one shape;
|bars| / |dots| a set of marks, one per datum; a |slice| / |bubble| one
each, per node.
Inside a chart, |line| reads data: / fn: (data space); the standalone |line|
primitive (SPEC 7) reads points: (pixels) — the chart layout branches on which.
A line carries markers at every datum, reusing the core marker: family generalised
from line ends to every vertex: |line| { marker: circle } shows a marker at each point.
A chart marker is centred, so only the symmetric kinds apply — dot, circle
(a larger, hover-sized point), and diamond; the directional arrow / crow are an
error on a series (SPEC 21). Every marker carries the datum’s <title> — a
marked point is a hover target (14.8). |dots| is markers with no line,
circle by default; its diameter is width (height too for an ellipse), its shape
marker: — there is no size: property.
curve: sets a line’s / area’s interpolation: linear (default, straight segments),
smooth (a monotone cubic — curved, passes through every point, never overshoots;
parameter-free), or step (a staircase). bars: on the chart combines multiple
|bars| series: grouped (side-by-side, default), stacked (piled; the top is the sum),
or overlay (translucent, on top). radius rounds a bar’s corners. (Stacked areas are
deferred; areas overlay.)
A |bubble| is one mark per node — |bubble| "Name" { at: x y; value: N; fill: … }
places a bubble at data point (x, y), sized by value:. The chart scales bubbles by
area (area ∝ value); the smart label sits centred in the bubble when it fits — inside
the disc inset by clearance, on the chord at the text’s own half-height —
else beside it, else on hover. Reach for |bubble| when each is a distinct labelled entity; for many uniform
points, |dots| is terser.
14.3 Data & formulas
A series’ values come from data: (explicit) or fn: (computed) — never both. Both use
the core comma law (SPEC 2), so charts add no value form;
the item width is the discriminator:
| Source | Syntax | Meaning |
|---|---|---|
| categorical | data: 9, 15, 24, 18, 30 | scalar items → one value per category |
| points | data: 0 225, 60 225, 118 221 | x y items → points (numeric x; scatter) |
| formula | fn: (min(8/(x/100-1)^2, 2000)) | an expression in x, sampled at samples: |
Scalar and pair items never mix in one data: — data: 10 20 is one point
(SPEC 2); a space-separated list errors with the comma form.
A |line| / |area| needs ≥ 2 vertices; with categorical data the value count must match
the categories: count (SPEC 21).
A point’s x may be a date — a quoted ISO-8601 literal: data: "2026-01-01" 18, "2026-02-01" 25. YYYY-MM-DD, optionally THH:MM[:SS], optionally Z / ±HH:MM;
a bare date is date-only (midnight UTC), an offset keeps its instant, and rendering is
timezone-independent (all math in UTC). Date x-values make the x axis a time axis
(14.4); dates and plain numbers never mix in one domain,
and an invalid date is an error (SPEC 21). Time-only literals don’t exist —
a numeric axis covers them.
labels: is the per-datum text — a quoted-string list parallel to data:
(one entry per value or x y point), distinct from the series’ one legend label
(its smart label). An entry rides with its datum: on the plot beside the point, or
on hover when there’s no room — the placement is tooltip:’s job
(14.8). The count must equal the data count; labels: needs
discrete data: (a sampled fn: has no authored points, so labels: with fn: is
an error). A per-node mark (|bubble|, |slice|, |mark|) takes no labels: —
its one smart label is its point label.
|line| "GLM-5.2" { data: 35 63, 42 72, 84 75; labels: "Non-Thinking", "High", "Max"; marker: circle }
Formulas are the core expression engine (SPEC 10.7):
operators, the math library, name = expr; locals, the ternary, and stylesheet functions.
Charts bind two ambient names — the same seam that injects u for parametric points::
x the x-axis data value (a whole-domain fn: uses it) and u a band-local clock
0 → 1 (14.5). A fn: is not folded at resolve
(its x is unbound there) but held and sampled at chart layout, once the x-domain is
fixed — a (…) group, a bare call (fn: sin(x)), a name, or a constant alike.
Locals chain derivations in one group; a stylesheet function keeps twins DRY:
{ ramp(s) = min(100, 25 + 1.572*(x/s) + 0.0142*(x/s)^2); }
|chart| [
|area| "Steel" { fn: ramp(1) }
|line| "Aluminum" { fn: ramp(1/0.7) }
]
The formula ceiling. fn: expresses a function of x, not a recurrence: a numeric
integration (a running sum) has no closed form and ships as precomputed data: points.
14.4 Axes, scales & domain
An axis is an |axis| child of a layout: chart (an #id is optional, used to bind —
a series or annotation reads an axis with axis:); its smart label is the axis title.
A chart with no |axis| gets an x (domain) axis and an auto-fit value axis, so simple
charts declare none — an axis is written only to say something.
| Property | Value | Notes |
|---|---|---|
side | bottom · left · right · top | cartesian only; several on one side stack outward in source order |
range | a b (each end a number, a quoted date, or auto) | the data window — and crop, and reverse (below) |
scale | linear · log · time | log walks the decade ladder — 1-2-5 per decade, thinning to the room (below); its domain must be above 0. time reads date literals (below) |
step / ticks | number / list · calendar (time) | tick spacing, or explicit ticks; omitted → nice ticks |
format | family + args (SPEC 17) | tick-value presentation; inherits from the chart |
unit | "%" | a quoted suffix appended to tick labels (and tooltips) |
gridlines | none · colour | this axis’s gridlines: none, or a colour (a colour turns them on) |
stroke / color / font-size | core | stroke tints the axis line + ticks, color the labels + title |
An x (domain) axis is categorical when categories: gives it labels (or by default,
indices 1…N) and numeric when the data is points or a fn:. A value axis carries
series magnitudes; axis: <id> on a series binds it (default: the first value axis of the
series’ orientation). Multiple value axes share a plot for dual-unit charts; only the
primary value axis and the x axis draw gridlines by default, so a normal grid appears
and a second value axis adds none (avoiding moiré). The default tint is the
--lini-grid role variable (SPEC 10.1).
range: a b does three jobs at once: it sets the visible window (a…b),
crops data outside it to the plot area, and reverses the axis when a > b
(range: 50 1 runs high→low — both scale and tick order flip). Either end may be auto
(range: 0 auto); the two ends must be distinct (SPEC 21). Ticks are “nice” by
default (1-2-5 × 10ⁿ); step: sets a spacing, ticks: an explicit list, scale: log
decade ticks (domain above 0).
A log axis thins to its room. The decade ladder has rungs — 1-2-5 per
decade, the decades alone, then every second, third, … decade — and the axis
draws the densest whose ticks stand a text row apart along its own extent.
Only the drawn ticks change: the domain, and so every datum’s position, is the
ladder’s and not the rung’s. An explicit ticks: is never thinned.
Tick labels come from categories: (an x axis) or the
formatted tick value + unit: (a value axis) — format: sets the value’s presentation
(SPEC 17);
labels: is the series’ per-datum text (14.3).
scale: time — a numeric domain in epoch seconds, set by date literals in data:
(14.3). Everything measured on a dated domain is written in the
same literals — range:, ticks:, a |band|’s range:, a |mark|’s at:
(14.5) — and the other kind is an error, either way round:
one domain, one kind. Ticks are
calendar-aware: auto picks the boundary unit from the span (years → months → weeks →
days → hours → minutes) and lands on calendar boundaries; step: takes a calendar
interval — a unit ident with an optional count (step: month, step: 2 week) — and a
plain number errors, pointing at the calendar form (SPEC 21). Tick text
follows the tick unit (years read 2026, months Jan 2026, days Mar 4, finer
04:30); an explicit format: date preset wins (SPEC 17).
14.5 Bands & annotations
Both are children placed in data coordinates; the model gives them for free.
axis: names the axis they measure against and is required on a |mark|.
A |band| partitions an axis and drives three things from one declaration: a
background shade, a tick (its smart label), and the segment boundaries every
series shares. range: a b is its data range on its bound axis: — the same
interval shape the axis itself reads (14.4);
fill: none makes it a divider + label with no shading.
|band| "Inject" { range: 1.4 3.1; axis: time; fill: --rose }
A series opts into segmentation with a per-band fn: list — one (…) expression
(or a bare constant) per band, comma-separated (fn: (u*10), 5, (ramp(2))), evaluated
in local u; a single fn: samples the whole
domain in x and ignores bands. Consecutive segments connect end-to-start (the riser is
drawn), so a jump is explicit. A per-band list whose length ≠ the band count is an error
(SPEC 21) — never a silent truncation.
A |mark| places a reference line, point, or label by value on a named axis, so it
survives a direction flip unchanged:
| Form | Draws |
|---|---|
|mark| "100 °C" { at: 100; axis: temp } | a reference line at value 100, across the plot perpendicular to temp |
|mark| "60 °C — 19 min" { at: 19 60; axis: temp } | a point (dot + label): x = 19, value 60 |
|mark| "safe" { at: 170 4; axis: temp; marker: none } | a label only (no dot) |
at: V (one value) is a line, at: X Y (two) a point; marker: none suppresses a point’s
dot, leaving the label — so there is no separate free-label node. A reference line’s
label stands clearance clear of its line, never on it — beside the top
end (a row chart’s, the bottom), taking the other flank where the plot’s edge
is nearer (14.6). Bands and marks render in
column and row directions; in radial they are a compile error until built
(SPEC 21, SPEC 24) — never a silent drop.
14.6 Legend, title & colour
One smart-label rule, placed by where the label sits: on the |chart| / |pie| → the
title (a caption above the plot); on a series / |slice| → a legend entry with a
swatch mirroring its paint (fill and edge); on an |axis| → the axis title; on a
|band| → a tick tinted its fill; on a |mark| → the annotation’s label. A
legend appears automatically at ≥ 2 entries (legend: is deferred —
SPEC 24, 14.1).
gap holds the plot off its chrome; clearance holds a text off what it
labels. gap: is the gutter outside the plot — to the title, to the
legend. clearance: is the daylight inside it, measured
edge to edge on the ink: a tick off the plot edge, an axis title off its tick
row, a band’s name off the row above, a spoke’s category off the web rim, a
|mark|’s label off its line, a per-datum label off its mark. It is the
ordinary cascading clearance (SPEC 9) — a chart draws no links,
so this is its whole reading — and the plot yields the room, never the
label.
The type scale is two steps — the title, and every other string a chart draws — each stated by a rule, never inline on a leaf (SPEC 18). Weight separates the registers: chrome (title, legend) semibold, data text (ticks, per-datum and annotation labels) normal, so the numbers read quietly beneath the captions. A leaf carries only its own colour — a band tick tinted its fill, a mark its stroke.
Colour. Explicit stroke: / fill: wins. Otherwise series walk the palette
(SPEC 10.2) in declaration order, skipping red (reserved for
danger), repeating if exhausted — deterministic, and interleaved around the
hue wheel (adjacent series read as distinct, the common 2–4-series case getting
the strongest contrast):
--rose --teal --orange --sky --amber --purple --green --blue --lime --gray
(A mindmap’s branch walk is the different job — wheel order, red and grey skipped — SPEC 8.)
Each series takes its hue at the tier the role wants — the outlined look: a |bars| /
|area| / |slice| fills with the soft tier and gains a deep edge (stroke: none removes it — a flat fill); a line takes the deep stroke, dots the ink. An
explicit fill: keeps its colour and still gains a deep edge of it. In layout: pie the
walk is per slice — the one place colour walks per datum rather than per series.
Per-datum paint rides the comma law on the repeated-mark series — |bars| /
|dots| only: fill: / stroke: / opacity: take a comma list, one item per datum,
where auto is the paint that datum would get anyway (the walk, the deep-edge rule) —
fill: auto, auto, --red, auto highlights one bar, and with no authored stroke each
datum’s default deep edge deepens its own fill. The count must equal the count of
explicit data: (a sampled fn: has no authored data — SPEC 21);
the legend swatch keeps the series’ base paint. A list on
|line| / |area| is an error — one shape has one paint, no ambiguous interpolation;
|slice| / |bubble| / |mark| are already per-node.
14.7 Direction, radial & pie
direction orients the chart — the same property a flow uses to pick its axis, plus
radial: column (default, cartesian, bars grow up), row (cartesian, bars grow right),
radial (polar, bars grow outward). The flip is never silently lossy — nothing is
authored in screen coordinates (categories:, series data:, and annotations bound to
a named axis with at: / span: are all logical), so direction only changes how
that plane is projected, and what a direction cannot yet draw errors instead of
vanishing (a radial band / mark — SPEC 21). An explicit axis side: is a
screen edge and is honoured as written.
Radial (direction: radial) projects the cartesian model into polar coordinates: the
x (domain) axis bends into a ring (categories → evenly-spaced spokes, from the top,
clockwise) and the value axis becomes the radius. A radar |line| connects a series’
value on every spoke and closes to the first; an |area| fills that polygon; |bars|
fill their angular slot. A radial chart has one value (radius) axis — writing side:
on it is an error (SPEC 21) — and one x axis (the spokes). Concentric circular
gridlines and a configurable start angle are deferred; the polygon web is the default.
Pie (layout: pie) encodes value as angle — each slice’s angle is its value over
the total — a different scale from radial’s value-as-radius, hence its own layout. No axes;
its children are |slice| nodes:
|pie| "Spend" { hole: 0.5 } [
|slice| "Ads" { value: 40 }
|slice| "SEO" { value: 30 }
|slice| "Direct" { value: 30 }
]
A |slice|’s value: is its magnitude (≥ 0), its smart label its legend entry; slices
fill clockwise from the top, each angle = value / Σ value × 360°, and walk the palette
(so slices are distinctly coloured). A total of zero is an error. hole: (0 ≤ n < 1)
cuts an inner hole — hole: 0 a pie, hole: 0.5 a donut. On-slice value labels, a centred
total, and exploded slices are deferred (SPEC 24).
14.8 Tooltips
A datum’s label has two presentations, and one property — tooltip: — sets how much
shows where. Hover is the only interactivity, with no script:
tooltip: | On the plot (inline) | On hover | For |
|---|---|---|---|
none | — | — | a clean static plot, no labels |
hover | — | card + <title> | labels on demand |
auto (default) | where it fits, else falls to hover | card + <title> | the printable default |
always | every label, forced | card + <title> | export — every label must read |
The two texts complement: the inline label is the datum’s own text — a series’
labels: entry, or a per-node mark’s smart label — while hover shows its value. So a
point can read Max on the plot and GLM-5.2: 75% on hover, never competing.
The hover floor is always honest. A labelled mark carries a native <title> — its
accessible name, readable in any renderer and surviving --static. Over it, a live CSS
:hover rule reveals a hidden <g class="lini-chart-tip"> card built from primitives,
positioned beside the point; the card is live-only (a baked SVG keeps the <title> and
drops the :hover). Only tooltip: none strips the <title> too.
Inline placement is one greedy pass, not a solver: each label tries a few offsets and
takes the first that clears the labels already placed and stays in the plot (a seat must
also sit off the series lines). Under auto a label with nowhere to sit drops to its hover
card; under always it is placed regardless. Inline labels are small and muted (color:
overrides, default --muted) and carry pointer-events: none. tooltip: cascades: set on
the |chart| it defaults every series; a series overrides it. Hit targets stay sparse — a
sampled curve draws at samples: density but a marker sits only at data / turning points,
so node count stays bounded.
14.9 Lowering
layout: chart / pie resolve in the layout phase (SPEC 19), since the
shared scale needs every child’s data first: collect series and resolve data: /
sample fn:; fix each axis domain and scale (bars force zero); inset the plot rect
by measured label / legend gutters; lower every series, axis, band, annotation, and the
legend to primitives at baked pixels; emit in a semantic draw order — bands →
gridlines → areas → bars → lines → dots → annotations → axes → labels → inline labels →
tooltip — so a line sits above its bars without hand-ordering (the one place a chart
overrides source-order rendering; layer: still wins). The output is an ordinary primitive
subtree (SPEC 19).