canvas.graph
A plotting environment on the scene's canvas. Its body holds marks — scatter, curve, line, the shapes, fit and the surfaces — and the parameters that drive them, each of which gets a slider in the environment footer. It draws its own frame, and the x and y domains are animatable with !cue.to.
#The graph environment
is a plotting surface: its body holds marks, it draws its own frame, and its domains are animatable. It carries the environment's id — canvas.graph#projectile{...} — and the frame is set by x-domain / y-domain (derived from the data when omitted), x-axis-label / y-axis-label, show-grid, show-axes, show-ticks, and show-background as the master switch. show-ticks: false hides the axis's own numbers and nothing else — names stay, and the gutter shrinks to what is left in it.
#Planes
A graph's head names its plane — what a coordinate on its marks is. canvas.graph:xy is the ordinary plane and the default; canvas.graph:x a number line, with one axis and marks that take x alone (a scatter there is a dot plot, repeated values stacking, and a boxplot takes a lane above the line); canvas.graph:argand the complex plane, whose axes are re and im and whose marks take one complex z — point(z: 3 + 2i), point(z: (3 + 2i)^2), with i a constant and every expression complex-valued — or re and im apart, which is how two columns bind; canvas.graph:polar, whose axes are r and theta, the angle in radians unless written 60deg. A pair — a segment's ends, an angle's corner — is (a, b) on every plane, read in the plane's own axes.
canvas.graph:argand{
vector(z: 3 + 2i; label: $z$)
point(z: (3 + 2i)^2; label: $z^2$)
circle(z: 0; size: abs(3 + 2i); fill: false)
curve(z: sqrt(13) * e^(i * t); t: [0, 2pi])
axis.label(im: 2; label: $2i$)
}(
re domain: [-4, 14]
im domain: [-6, 14]
)canvas.graph:polar{
curve(expression: 1 + cos(theta))
point(r: 2; theta: 60deg)
axis.band(r: [1, 2])
axis.band(theta: [0, pi/4])
}(
r domain: [0, 3]
)A plane admits the marks it can draw and nothing else — the compiler says which when one is refused, and why. The argand plane has no independent axis, so fit, integral and curve: f are not drawn there (a curve on it is z(t)); the polar plane refuses integral, whose area is not the one it computes; the number line draws positions and series and nothing that needs a second axis. A bar on the polar plane is a rose, standing at names on theta; a histogram there is a wind rose, its bins edges on theta. The same rules hold for a mark inside a cue. Every plane's axis attributes are spelled with the axis's name in front — re domain:, r domain:, theta format: — and the other planes' are refused.
#Axes
An axis has two properties, written with its name in front and combining with each other and with any plane. x format: is what the axis holds: numbers; radians, which sets each label as a fraction of π and rules the axis in π — its automatic grid step comes off the π ladder, π/4, π/2, π, 2π; degrees, whose values are still radians and whose labels read 30°; dates, which places 2024, 2024-03, 2024-03-15 and a date column at their fractional years and ticks by span; the preset series months and weekdays (ticks Jan…, Mon…); and a written list — x format: Red, Blue, Yellow — or a dataset column — x format: #sales.region, its distinct cells in row order. A series makes the axis categorical: the names stand at positions 1…n, every mark on the axis speaks them (line(x: Mon, Tue, Wed; y: 12, 19, 15)), a column of names binds directly, and a name the axis does not have is a compile error. A continuous mark — , , — is refused on a categorical axis: there is nothing between two names to draw it over.
auto, the default, is decided from the marks: a categorical series on the axis rules it in those names, in the order they first appear; a date column or literal rules it in dates; otherwise the axis holds numbers. Write the values out when the order or the full set matters — a Likert scale, a category with no data that must still appear.
x scale: is how the axis places a value: linear, log (v at log₁₀ v) or log2. A transform, not a relabelling — the data is written as measured, scatter(x: 10, 100, 1000), and the axis puts each reading at its logarithm: curves are sampled in it, a fit on log–log gives the exponent as its slope, axis.band(x: [100, 1000]) is one decade, domains and !cue.zoom windows stay in data units, and the ticks are the decades written as values. A literal at or below zero is a compile error; a cell at or below zero drops its row. A scale beside names, dates or angles is refused, and it is never auto.
canvas.graph{
scatter#readings(x: 10, 100, 1000; y: 3, 9, 27)
fit(of: #readings; degree: 1)
}(
x scale: log
y scale: log
)canvas.graph{
@data#sales{
region | y2023 | y2024
North | 38.0 | 42.1
South | 33.5 | 31.4
East | 47.2 | 55.0
West | 26.1 | 27.8
}
bar(x: #sales.region; y: #sales.y2023; colour: grey; label: 2023)
bar(x: #sales.region; y: #sales.y2024; colour: blue; label: 2024)
}(
y axis label: revenue (£k)
)canvas.graph#fit-graph{
scatter#observations(x: 1, 2, 3, 4; y: 2.0, 2.4, 3.1, 4.8; colour: grey)
fit(of: #observations; model: polynomial; degree: 1; colour: teal)
}(
x-axis-label: x
y-axis-label: y
)#Marks
Everything drawn on the surface is a mark, and marks share a set of attributes: colour, label (rich text, so $maths$ sets properly), hidden (declared but not drawn — and animatable), lock (protects the mark on an editable: graph), and layer (paint order). The shapes are , , , , , , , , , , , , plus the between two of them and freestanding . Every outline takes dashed, which is how a figure draws the shape that is not there — where it was before it moved, a region a constraint rules out, a construction that explains the drawing without being part of it.
Three of them carry a figure's notation rather than its data. A takes ticks, the congruence hatches across its middle: two sides marked ticks: 1 are the same length, which is how a construction says so — the notation angle already has in arcs. spans two points with a curly brace and hangs its name at the tip — brace(start: (0, 0); end: (4, 0); label: $R$) — which is what axis.brace is for an interval on an axis, for a distance anywhere in the picture. It bows to the left of the direction it is written in, so swapping its ends (or flip) moves it to the other side, and its depth is in pixels, like an angle's radius. And takes error and error-x, one half-width per reading — a column of uncertainties bound whole draws a capped bar through every dot, and the frame grows to hold the caps.
#Curves
A plots an expression in x: curve(expression: x^2; colour: blue) — in theta on the polar plane, where it is r(θ). Expressions may reference parameters — a * sin(b * x) bends live as the reader drags a and b — and domain / range clip where it draws. A curve exposes derived max and min over its plotted domain. Or a curve is a position in t: curve(x: cos(t); y: sin(t); t: [0, 2pi]) on the ordinary plane, z(t) on the argand one, r and theta in t on the polar one — with the interval t runs over, and no expression.
#Data marks
takes its readings one axis per attribute, paired by position — scatter(x: 1, 2, 3; y: 2.0, 2.4, 3.1) — and either attribute binds a dataset column directly: scatter(x: #islands.area; y: #islands.species). and read x / y the same way, and both take interpolate: linear joins the readings, bezier rounds the corners off with a cubic through every one of them, and a line alone takes step, which holds each value until the next x and then jumps. A rounded line claims the quantity varied smoothly in between, so it belongs on a trajectory and not on a tally. reads x / y the same way and stands a bar at each reading: one axis must be categorical (the axis of names is the orientation), the other reaches the baseline. Several bars at one name split the band in source order; stack stands a bar on the one before it instead; width is the share of the band the bars fill. is the other rectangle mark and a different thing: its bins are edges, so its width is data, and it draws on a numeric axis. It takes the readings themselves — histogram(x: #sample.height; bins: 12) — and bins them, bins saying how many; parametised (bins: k) the reader re-bins the sample for themselves, which is the quickest way to see how much of the shape was the choice of grid. The coordinate the readings are written on is the axis the bins land on and so which way the bars grow (x up from the x axis, y out from the y axis, theta a rose on the polar plane). A sample already tallied is written the other way instead — values the counts and bins the edges they fall between, one more number than there are counts, with orientation for the direction no coordinate is there to say.
is the third of them and is for a quantity that stands at a point rather than filling an interval: a stem from the baseline with a dot at its end. lollipop(x: 0, 1, 2; y: 0.25, 0.5, 0.25) is a mass function written out, and it is the honest picture of one — P(X = k) belongs to the single value k, and a bar drawn there claims a width the quantity has not got. It takes the two forms a histogram does: a position and a value paired by position, or one axis alone, which is the readings — lollipop(x: #rolls.face) stands a stem of height three at every value that occurs three times, a tally by distinct value that invents no grid at all, which is what discrete data deserves instead of bins. A categorical axis is where the stems stand (names have no height), and with two numeric axes orientation says which way they run. The page covers where columns come from and which consumers bind them directly.
On the number line the same readings are a distribution. scatter(x: 2, 3, 3, 4) stacks equal values into a dot plot, and jitter spreads them across the line instead — a share of the mark's lane, each dot held at its own offset — which is what a sample of a few hundred readings has to be drawn as. On the xy and argand planes the same attribute nudges both coordinates, since both are the reading's own: the nudge is a share of the smallest non-zero gap between the values on each axis, at most half a gap either way, so tied readings come apart without any dot crossing the place a different reading would have been. It is refused on the polar plane, where an amount of theta is a different distance at every radius. takes the readings themselves and works the summary out: the box spans the quartiles, the median is ruled across it, and the whiskers run to the readings inside Tukey's fences, with whatever lies beyond drawn as its own dot (whiskers: extremes runs them to the smallest and largest instead, and !outliers keeps the fences but leaves those dots off — animatable, so a step can fade them in over a box the reader has already read). The quartiles are the hinges a class is taught to find — the median splits the ordered readings in two, itself in neither half when the count is odd, and each quartile is the median of its half. The marks that need room across the line take a lane each in source order, the first sitting on the line — except that a box and a scatter of the same readings are one sample drawn twice and share one, so writing both puts the summary over the readings with nothing to position by hand. Which is drawn over which is source order and layer, as for any pair of marks.
canvas.graph:x{
scatter(x: #trial.mass; jitter: 0.7; colour: grey)
boxplot#control(x: #trial.mass; colour: teal; label: control)
}(
x axis label: mass (g)
)median, q1, q3, iqr, min and max, so prose citing {{#control.iqr}} reads the width of the box beside it. min and max are the sample's extremes whether or not a whisker reaches them.On a plane a box says which coordinate its readings are — x: and it lies along the x axis, y: and it stands up the y axis — and on: where it stands on the other one. A number there stands it at that value; a name stands it at that name and rules the axis in the names its boxes stand at, so three samples side by side need no x format: line. The room a box gets is the band at its value — a unit on an axis of names, and on a continuous one the closest two boxes stand, so no box can overlap another — and width: is its share of that room. Several boxes standing at one value split it between them in source order, as several marks split the band at one name.
canvas.graph:xy{
boxplot(y: #trial.control; on: control; colour: blue)
boxplot(y: #trial.treated; on: treated; colour: orange)
}(
y axis label: mass (g)
)Written with both coordinates it is one bivariate sample, paired reading by reading as a scatter's are, and what it draws is the rectangle of the hinges — the x quartiles by the y quartiles — with the two medians crossed inside it, a whisker from each edge out to that axis's fence, and every reading outside either fence as its own dot. It is what a scatter looks like once it is summarised, and it composes with one: written over the same columns, the box lands on the very dots it summarises. A box of a pair of samples publishes its numbers under each axis's own name — {{#cloud.x-median}}, {{#cloud.y-q3}} — and no plain median, which a sample of pairs does not have. On the argand plane the pair is re: and im:, or one complex z:.
canvas.graph:xy{
scatter#cloud(x: #trees.height; y: #trees.mass; colour: grey)
boxplot(x: #trees.height; y: #trees.mass; colour: teal)
}#Fits and residuals
lays a least-squares fit through one scatter's observations, named in of: — its head slot, so fit: #observations is the same as fit(of: #observations). Give degree a parameter instead of a number and the reader drives it — Interaction shows that pairing. draws the vertical differences from a fit to the points it was found from: residuals(of: #trend; colour: orange), or just residuals where the graph holds a single fit.
A fit fits the mark it names and nothing else, so a graph can carry two of them: draw the sample twice, once whole and once without the point in question, and the two lines stand beside each other with their own slope and r2. To fit part of a sample, draw that part as its own scatter and fit that.
canvas.graph{
scatter#all(x: 1, 2, 3, 4, 5; y: 2.1, 3.9, 6.2, 7.8, 14.0; colour: grey)
scatter#typical(x: 1, 2, 3, 4; y: 2.1, 3.9, 6.2, 7.8; colour: blue)
fit#with-outlier(of: #all; degree: 1; colour: grey)
fit#without(of: #typical; degree: 1; colour: blue)
}slope, intercept and r2. The slope is the fitted polynomial's derivative at zero — exact, and live under whatever degree is in force — so prose citing {{#f.slope}} cannot disagree with the graph.#Integrals
shades the area under a referenced curve between two bounds — integral(of: #bell; from: -1; to: 1) — and exposes the computed area as derived value. Its bounds may be expressions, so a parameter can sweep the region while the reader watches the value change.
#Surfaces
Two marks plot a function of the plane — the one place y is legal in an expression alongside x. draws level sets, either levels: 8 evenly-spaced or at: 1, 2, 4 exactly; paints the field itself. Both take a colour that is a single hue or a ramp — multicolour (the full spectrum) or temperature (blue through white to red) — and expose derived min and max over the visible window.
canvas.graph{
heatmap(expression: sin(x) * cos(y); colour: temperature)
contour(expression: sin(x) * cos(y); at: 0; colour: grey)
}#Angles
marks the angle at a vertex and measures it: angle(at: (1, 1); from: (4, 1); to: (1, 4)) is the corner of a triangle, drawn with the arc — or, at a right angle, with the square a reader was taught to look for. An arm is written as either of the two things an arm is: a point it runs through, (4, 1), or a bearing in degrees anticlockwise from the positive x axis, 30. sweep: chooses which of the four ways round is meant — minor (the corner, and the default), reflex, or the signed anticlockwise / clockwise pair a sector or a rotation needs.
canvas.graph{
polygon(x: 0, 4, 0; y: 0, 0, 3; colour: grey)
angle#a(at: (0, 0); from: (4, 0); to: (0, 3))
angle#b(at: (4, 0); from: (0, 0); to: (0, 3); show-value: true; label: $\theta$)
}value (degrees) and radians, and show-value: true draws the reading on the plot. Both are the angle as drawn, so a caption citing {{#b.value}} cannot disagree with the arc beside it — including while a keyframed arm is still sweeping round.#Axis annotations
names a value on an axis — axis.label(x: 3.1416; label: $\pi$) puts π where the number would have gone, and line: true adds a rule across the plot at that value. A name on the y axis is measured into the gutter beside it, so a worded one gets the room it needs; past about a quarter of the frame it wraps onto a second line rather than growing wider. names an interval: a curly brace spanning x: [m, m + s] with its label at the tip, drawn in the gutter beside the tick numbers. shades an interval across the whole plot — a reference band the marks are read against, axis.band(label: recession; x: [2008, 2009.5]) — drawn under the grid and the marks. Which axis any of them speaks to is chosen by which coordinate or interval is given.
Regions are shaded two ways. takes the area under a function between two limits, and base says what it closes down to: the axis, unless it names another curve (base: #demand) or an expression of its own — base: 6 shades down to that height. The area between two curves is not a mark of its own because it is not an idea of its own, and the reported value follows the floor. A parametric takes fill instead, since its path can enclose something: a closed loop fills itself, and an arc closes across its own chord — close names a point to come back through first, so an arc closed through the centre of its circle is the sector. A y(x) curve is refused both: the region under a function is an integral, and saying so keeps one idea in one place.
#Derived attributes
Marks that compute something publish it for reading: a curve's max and min, a fit's slope, intercept and r2, an integral's value, a surface's min and max, a boxplot's median, q1, q3, iqr, min and max (under each axis's name where it summarises a pair of samples), a parameter's value. Reach one by member access on the mark's id — #f.slope in an attribute, {{#f.slope}} in prose. Derived attributes covers how they behave.
#Attributes
On any plane
x a number line, xy the ordinary plane (the default), argand the complex plane (re/im, or one complex z), polar (r/theta). The head form — canvas.graph:polar{…}. A plane admits the marks it can draw and withdraws the other planes' axis attributes; a mark inside a cue is held to it just the same.Can be written as shorthand: canvas.graph:valuexy$maths$ and inline formatting render. A named attribute: the head : names the plane.falsetruetrueof: 'lines' is squared paper, 'dots' steps back and lets the marks carry the picture. The -minor variants add unlabelled subdivisions between the labelled lines.linesx-domain: [0, 5] !cue.to{[0, 10]}(at: 1; over: 800ms). For a magnification, !cue.zoom draws a box around the new window and moves the frame into it: x-domain: [0, 10] !cue.zoom{[2, 4]}(at: 2; draw: 700ms; hold: 250ms). Endpoints may use parameters — y-domain: [0, 0.46 / s] frames the environment live. On a categorical axis the ends are names: [Tue, Thu]. The ordinary plane and the number line; the other planes name their own axes (re-domain, r-domain…).numbers; radians (ruled in π — the automatic grid step comes off the π ladder, ticks set as fractions of π); degrees (values in radians, ticks written 30°); dates (ISO literals 2024, 2024-03, 2024-03-15 and date columns at their fractional years, ticks by span); the preset series months and weekdays (ticks Jan…, Mon…; 1 = January, 1 = Monday); a written list (Red, Blue, Yellow) or a dataset column (#sales.region, its distinct cells in row order). A series makes the axis categorical: the names stand at positions 1…n, every mark on the axis speaks them, a column of names binds directly, a name the axis does not have is a compile error, and a continuous mark — curve, fit, integral, contour, heatmap, residuals — is refused. auto, the default, is decided from the marks: a categorical series rules the axis in its names, a date column or literal makes it dates, otherwise numbers.autolinear, log (v at log₁₀ v) or log2. A transform, not a relabelling: curves are sampled in it, a fit on log–log gives the exponent as its slope, axis.band(x: [100, 1000]) is one decade, domains and !cue.zoom windows stay in data units, ticks are the decades written as values (10, 100, 10⁴ past ten thousand). A literal ≤ 0 is a compile error; a cell ≤ 0 drops its row. Refused beside names, dates or angles.linearx-domain: [0, 5] !cue.to{[0, 10]}(at: 1; over: 800ms). For a magnification, !cue.zoom draws a box around the new window and moves the frame into it: x-domain: [0, 10] !cue.zoom{[2, 4]}(at: 2; draw: 700ms; hold: 250ms). Endpoints may use parameters — y-domain: [0, 0.46 / s] frames the environment live. On a categorical axis the ends are names: [Tue, Thu]. The ordinary plane and the number line; the other planes name their own axes (re-domain, r-domain…).!cue.to or !cue.zoom — write the same anchor on both domains and the two axes move as one window. Endpoints may use parameters — y-domain: [0, 0.46 / s] frames the environment live. On a categorical axis the ends are names: [Tue, Thu]. The ordinary plane and the number line; the other planes name their own axes (re-domain, r-domain…).numbers; radians (ruled in π — the automatic grid step comes off the π ladder, ticks set as fractions of π); degrees (values in radians, ticks written 30°); dates (ISO literals 2024, 2024-03, 2024-03-15 and date columns at their fractional years, ticks by span); the preset series months and weekdays (ticks Jan…, Mon…; 1 = January, 1 = Monday); a written list (Red, Blue, Yellow) or a dataset column (#sales.region, its distinct cells in row order). A series makes the axis categorical: the names stand at positions 1…n, every mark on the axis speaks them, a column of names binds directly, a name the axis does not have is a compile error, and a continuous mark — curve, fit, integral, contour, heatmap, residuals — is refused. auto, the default, is decided from the marks: a categorical series rules the axis in its names, a date column or literal makes it dates, otherwise numbers.autolinear, log (v at log₁₀ v) or log2. A transform, not a relabelling: curves are sampled in it, a fit on log–log gives the exponent as its slope, axis.band(x: [100, 1000]) is one decade, domains and !cue.zoom windows stay in data units, ticks are the decades written as values (10, 100, 10⁴ past ten thousand). A literal ≤ 0 is a compile error; a cell ≤ 0 drops its row. Refused beside names, dates or angles.linear!cue.to and !cue.zoom like x-domain.y-domain.autor scale: log the start is the radius at the centre.[0, pi/2] draws a quarter turn. The full turn when omitted. The window an r(theta) curve runs over.radians (the default — rays at π/6, labels as fractions of π), degrees (rays every 30°, labelled 30°), or a series of names (North, East, South, West, weekdays, a column) for a radar chart's spokes or a rose's sectors, standing at equal angles round the turn.autolinear or log/log2, which rings at the decades from the r-domain's start outward.linear#Allowed content
#Allowed in
#Examples
canvas.graph#fit-graph{
scatter#observations(x: 1, 2, 3, 4; y: 2.0, 2.4, 3.1, 4.8; colour: grey)
fit#trend(of: #observations; degree: 1 to 4; default: 1; colour: teal)
cue{
residuals(of: #trend; colour: orange)
}(
in: 1
)
}(
x-axis-label: x
y-axis-label: y
)#Notes
- An environment's own id is written on the node —
canvas.graph#projectile{…}— andcontinue:points at it with#projectile.