14. Charts

A chart is a layoutlayout: 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

LayoutTemplateEncodesChildren
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):

PropertyLayoutValueDefault
directionchartcolumn · row · radialcolumn
barschartgrouped · stacked · overlaygrouped
categorieschartquoted-string list — the x-axis (or spoke) labelsindices 1…N
sampleschartinteger — fn: sample count24
holepie0 ≤ n < 1 — inner-radius fraction (a donut)0
legendbothtop · right · bottom · none ⌛ (SPEC 24) — writing it is an error until the reader landsauto (shown when ≥ 2 entries) — built
tooltipbothnone · hover · auto · always (14.8)auto
gapbothnumber — clear space between the plot and the title / legend outside it15
clearancebothnumber — 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):

SeriesLayoutDrawsLowers toPaint
|line|charta polyline through the data (a closed loop when radial)|line| / |path|stroke, stroke-width, stroke-style
|area|charta line filled to a baseline|poly| / |path| + |line|fill, stroke, baseline
|bars|chartone bar per datum (a wedge when radial)one |rect| / |poly| eachfill, stroke, radius
|dots|chartone marker per datumone |oval| / marker eachfill, stroke, marker
|bubble|chartone bubble at a point, sized by value:one |oval|fill, stroke
|slice|pieone wedgeone |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:

SourceSyntaxMeaning
categoricaldata: 9, 15, 24, 18, 30scalar items → one value per category
pointsdata: 0 225, 60 225, 118 221x y items → points (numeric x; scatter)
formulafn: (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) }
]
0 10 20 30 0 0.2 0.4 0.6 0.8 1 Steel Aluminum

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.

PropertyValueNotes
sidebottom · left · right · topcartesian only; several on one side stack outward in source order
rangea b (each end a number, a quoted date, or auto)the data window — and crop, and reverse (below)
scalelinear · log · timelog 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 / ticksnumber / list · calendar (time)tick spacing, or explicit ticks; omitted → nice ticks
formatfamily + args (SPEC 17)tick-value presentation; inherits from the chart
unit"%"a quoted suffix appended to tick labels (and tooltips)
gridlinesnone · colourthis axis’s gridlines: none, or a colour (a colour turns them on)
stroke / color / font-sizecorestroke 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 (ab), 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 literalsrange:, 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:

FormDraws
|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 }
]
Ads: 40 (40%) SEO: 30 (30%) Direct: 30 (30%) Spend Ads SEO Direct Ads: 40 (40%) SEO: 30 (30%) Direct: 30 (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 hoverFor
nonea clean static plot, no labels
hovercard + <title>labels on demand
auto (default)where it fits, else falls to hovercard + <title>the printable default
alwaysevery label, forcedcard + <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).