Asymptote, figures drawn by the build and redrawn in the page
Asymptote is the engine I wrote for the articles RAMTT publishes on ramtt.com. In Asymptote a chart in an article is a data file and a few lines of configuration. The build draws it to finished markup, the page takes over when the reader arrives, and the same function does both. This note rebuilds two of its figures for this site: one drawn from a measurement, and one with sliders that you can move.
How figures are built
One function, two places
A picture of a chart cannot be read by a screen reader, does not change with the theme and cannot answer a question about a value. A live figure can do all three. A live figure is drawn from data by the same function at build time and in the page. It exists without JavaScript, and the layout does not shift when the script loads.
The chart’s markup is a pure function of its configuration. The build calls the function and writes the result into the HTML. When the reader arrives, the page calls the same function and attaches the interaction to the markup that is already there. The markup is identical in both places, so nothing on the page moves.
| Delivered as | Size |
|---|---|
Source Measured by Asymptote’s scripts/figure-sizes.mjs on the built ramtt.com, retrieved . Method: measured. Its hash is e68ce7926f53, and its license is .
The figure above is the measurement Asymptote made on the built ramtt.com: the same line chart from one of RAMTT’s articles, delivered four ways. The markup of the live figure, with its hidden data table, is about a tenth of the size of the PNG. The shared chart script is not counted, because a page loads it once for all its figures.
The source of the data
A figure built from a data file shows the source of that file under it: its name, the method, the date it was retrieved and a hash of its content. The method sets the mark on the figure, Measured or Estimated. An author cannot label an estimated series as measured, because the build compares the two and stops.
When a data file changes under an article that has not changed, Asymptote marks the figure as changed until someone accepts the new numbers. Synthetic data is marked as synthetic: the figures in RAMTT’s articles on critical power use an example rider, and their source line names the rider as an example.
The figure above follows the same rule on this site. The note’s build reads the method from the data file and stops when the figure’s mark differs from it. It then writes the source line with the hash under the figure. This is the file the figure is drawn from:
{
"source": {
"name": "Measured on the built site by scripts/figure-sizes.mjs",
"retrieved": "2026-09-20",
"method": "measured"
},
"unit": " KB",
"sampleLabels": [
"PNG, 2×",
"WebP, 2×",
"SVG of the plot, gzipped",
"Live figure markup, gzipped"
],
"values": [
21.3,
20,
0.4,
2.5
]
}
What it costs
In Asymptote the chart script is part of the one script every article loads. An explorable figure loads a second small script, and only on a page that has one. On this page both figures, the expression language and the reading of the plot are in the note’s own script, which loads after the text.
Figures you can move
A chart with sliders
A chart of one equation at one set of numbers shows one case, and a reader often wants the next one. For those questions Asymptote has the explorable figure: a chart whose curves are equations, with a slider for each parameter. It is drawn in full at build time, and the page redraws it as the reader moves a slider.
In RAMTT’s articles the equations are physiological models, and those stay in RAMTT. The figure below draws the motion rule of this site instead. Each frame moves a value toward its target by a share of the distance left. The share depends on the time since the last frame and on a time constant, so the value starts at full speed and then slows down.
| Time | The equation | As the frames draw it |
|---|---|---|
A link in the text can set the figure. Try the time constant of a control, the time constant of something thrown or half the frame rate, and the sliders and the curves take the values the link names.
The figure is text
An author writes an explorable as a block in the article’s Markdown. The block names the horizontal axis, the parameters with their range and starting value, and each curve as an expression of the parameters and x. It holds only data, with no functions and no loops, and the build checks every part of it before anything reaches a reader. This is the figure above as the note’s build reads it:
{
"title": "How far a value has moved toward its target",
"x": {
"from": 0,
"to": 1000,
"steps": 201,
"unit": " ms"
},
"params": [
{
"id": "tau",
"label": "Time constant",
"min": 20,
"max": 400,
"step": 10,
"value": 120,
"unit": "ms"
},
{
"id": "dt",
"label": "Frame interval",
"min": 4,
"max": 100,
"step": 1,
"value": 17,
"unit": "ms"
}
],
"series": [
{
"name": "The equation",
"expr": "100 * (1 - exp(-x / tau))"
},
{
"name": "As the frames draw it",
"expr": "100 * (1 - exp(-floor(x / dt) * dt / tau))",
"look": "step"
}
],
"yTicks": [
0,
25,
50,
75,
100
],
"unit": " %",
"readout": "After one time constant, {tau|0} ms, the value has moved {100 * (1 - exp(-1))|0} % of the way. After three, {3 * tau|0} ms, it has moved {100 * (1 - exp(-3))|0} %, and frames {dt|0} ms apart have drawn it in {floor(3 * tau / dt)|0} steps."
}
A language with no eval
The expressions are written in a small language of their own. It has numbers, the parameters, x, the four arithmetic operators, powers, comparisons, && and ||, a ternary, sixteen functions from min to lerp, and the constants pi and e. This is its whole grammar:
expr := ternary
ternary := or ('?' expr ':' expr)?
or := and ('||' and)*
and := cmp ('&&' cmp)*
cmp := add (('<'|'<='|'>'|'>='|'=='|'!=') add)?
add := mul (('+'|'-') mul)*
mul := unary (('*'|'/'|'%') unary)*
unary := ('-'|'!') unary | pow
pow := atom ('^' unary)?
atom := number | name | name '(' args ')' | '(' expr ')'
The parser turns an expression into a tree, and an evaluator walks the tree with the current values. The page never passes a string to eval or new Function. The content security policy of ramtt.com and of this site forbids evaluating a string as code. A figure that needed it would need the policy loosened for every page. With a language of its own, the policy stays as strict as it is on a page with no figures.
The language can describe the equations the articles are about. It cannot read the page, call the network or loop, and an author who needs more than that needs a figure built another way.
The build draws it and checks it
When the build meets an explorable, it evaluates every series at every sample of x with the starting values and draws the result with the same chart function as every other figure. A reader without JavaScript sees the figure at its starting values, with its title, axes and a hidden data table for screen readers. A reader with JavaScript sees the same markup, and the sliders are enabled when the script loads.
The build stops with the figure and the reason when a check fails. Every parameter needs a numeric minimum, maximum, step and starting value, with the starting value inside the range. Every series has to parse and may name only the parameters and x. The readout sentence has to evaluate with the starting values. A typo in a variable name is therefore a failed build instead of a curve that draws as zero. The note’s build runs the same three checks on the figure above.
The page redraws it
When the reader moves a slider, the script reads every slider, evaluates each series at each sample and redraws the lines with the chart function. It redraws once per animation frame, however fast the slider moves. A value the language cannot compute, such as a division by zero, becomes a gap in the line instead of a spike.
The readout under the sliders is a sentence with expressions in braces, such as {3 * tau|0}, where the number after the bar is the number of decimals. The same language evaluates it, so the sentence and the curve always show the same values. The pointer and the arrow keys read the plot one sample at a time.
The state is the address
Every change writes the figure’s state into the page’s address, as #fig-follow:tau=50,dt=17. Opening that address restores the sliders and the curves, so one reader can send another a link to a setting. Moving the sliders back to their starting values removes the state from the address, and the Reset button appears only when something has changed.
Always estimated
An explorable draws equations instead of measurements. Every explorable in Asymptote carries the mark Estimated on the figure, whatever the article around it is about. The curve shows what the equation gives for the numbers the reader chose, and the article says what the equation leaves out. The one above leaves out that a browser does not draw every frame at the same interval.
The source of this note is in the portfolio’s repository under web/notes/asymptote/: expr.js for the language, chart.js for the markup, figures.mjs for the two figures and their checks, asymptote.test.mjs for the tests. Asymptote’s own source belongs to RAMTT and is not published.