Skip to the content
W08NotesMade for RAMTT

Asymptote, figures drawn by the build and redrawn in the page

Asymptote is the engine I wrote for the ar­ti­cles RAMTT pub­lishes on ramtt.com. In Asymptote a chart in an ar­ti­cle is a data file and a few lines of con­fig­u­ra­tion. The build draws it to fin­ished markup, the page takes over when the reader ar­rives, and the same func­tion does both. This note re­builds two of its fig­ures for this site: one drawn from a mea­sure­ment, and one with slid­ers that you can move.

PostedKindNotesSeriesMade for RAMTT

How fig­ures are built

One function, two places

A pic­ture of a chart cannot be read by a screen reader, does not change with the theme and cannot answer a ques­tion about a value. A live figure can do all three. A live figure is drawn from data by the same func­tion at build time and in the page. It exists with­out JavaScript, and the layout does not shift when the script loads.

The chart’s markup is a pure func­tion of its con­fig­u­ra­tion. The build calls the func­tion and writes the result into the HTML. When the reader ar­rives, the page calls the same func­tion and at­taches the in­ter­ac­tion to the markup that is al­ready there. The markup is iden­ti­cal in both places, so noth­ing on the page moves.

Bytes for one chart, by how it is deliveredMeasured
The size in kilobytes of the same line chart delivered as a PNG, a WebP, a gzipped SVG of the plot and the gzipped markup of the live figure
Delivered asSize

Source Measured by Asymptote’s scripts/figure-sizes.mjs on the built ramtt.com, re­trieved . Method: mea­sured. Its hash is e68ce7926f53, and its li­cense is .

The figure above is the mea­sure­ment Asymptote made on the built ramtt.com: the same line chart from one of RAMTT’s ar­ti­cles, de­liv­ered 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, be­cause a page loads it once for all its fig­ures.

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 re­trieved and a hash of its con­tent. The method sets the mark on the figure, Measured or Estimated. An author cannot label an es­ti­mated series as mea­sured, be­cause the build com­pares the two and stops.

When a data file changes under an ar­ti­cle that has not changed, Asymptote marks the figure as changed until some­one ac­cepts the new num­bers. Synthetic data is marked as syn­thetic: the fig­ures in RAMTT’s ar­ti­cles on crit­i­cal power use an ex­am­ple rider, and their source line names the rider as an ex­am­ple.

The figure above fol­lows the same rule on this site. The note’s build reads the method from the data file and stops when the figure’s mark dif­fers 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 ar­ti­cle loads. An ex­plorable figure loads a second small script, and only on a page that has one. On this page both fig­ures, the ex­pres­sion lan­guage and the read­ing 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 equa­tion at one set of num­bers shows one case, and a reader often wants the next one. For those ques­tions Asymptote has the ex­plorable figure: a chart whose curves are equa­tions, with a slider for each pa­ra­me­ter. It is drawn in full at build time, and the page re­draws it as the reader moves a slider.

In RAMTT’s ar­ti­cles the equa­tions are phys­i­o­log­i­cal models, and those stay in RAMTT. The figure below draws the motion rule of this site in­stead. Each frame moves a value toward its target by a share of the dis­tance left. The share de­pends on the time since the last frame and on a time con­stant, so the value starts at full speed and then slows down.

How far a value has moved toward its targetEstimated
The share of the way a value has moved toward its target during the first second, as the equation gives it and as frames at a fixed interval draw it
TimeThe equationAs the frames draw it

A link in the text can set the figure. Try the time con­stant of a con­trol, the time con­stant of some­thing thrown or half the frame rate, and the slid­ers and the curves take the values the link names.

The figure is text

An author writes an ex­plorable as a block in the ar­ti­cle’s Markdown. The block names the hor­i­zon­tal axis, the pa­ra­me­ters with their range and start­ing value, and each curve as an ex­pres­sion of the pa­ra­me­ters and x. It holds only data, with no func­tions and no loops, and the build checks every part of it before any­thing 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 ex­pres­sions are writ­ten in a small lan­guage of their own. It has num­bers, the pa­ra­me­ters, x, the four arith­metic op­er­a­tors, powers, com­par­isons, && and ||, a ternary, six­teen func­tions from min to lerp, and the con­stants pi and e. This is its whole gram­mar:

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 ex­pres­sion into a tree, and an eval­u­a­tor walks the tree with the cur­rent values. The page never passes a string to eval or new Function. The con­tent se­cu­rity policy of ramtt.com and of this site for­bids eval­u­at­ing a string as code. A figure that needed it would need the policy loos­ened for every page. With a lan­guage of its own, the policy stays as strict as it is on a page with no fig­ures.

The lan­guage can de­scribe the equa­tions the ar­ti­cles are about. It cannot read the page, call the net­work or loop, and an author who needs more than that needs a figure built an­other way.

The build draws it and checks it

When the build meets an ex­plorable, it eval­u­ates every series at every sample of x with the start­ing values and draws the result with the same chart func­tion as every other figure. A reader with­out JavaScript sees the figure at its start­ing values, with its title, axes and a hidden data table for screen read­ers. A reader with JavaScript sees the same markup, and the slid­ers are en­abled when the script loads.

The build stops with the figure and the reason when a check fails. Every pa­ra­me­ter needs a nu­meric min­i­mum, max­i­mum, step and start­ing value, with the start­ing value inside the range. Every series has to parse and may name only the pa­ra­me­ters and x. The read­out sen­tence has to eval­u­ate with the start­ing values. A typo in a vari­able name is there­fore a failed build in­stead 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, eval­u­ates each series at each sample and re­draws the lines with the chart func­tion. It re­draws once per an­i­ma­tion frame, how­ever fast the slider moves. A value the lan­guage cannot com­pute, such as a di­vi­sion by zero, be­comes a gap in the line in­stead of a spike.

The read­out under the slid­ers is a sen­tence with ex­pres­sions in braces, such as {3 * tau|0}, where the number after the bar is the number of dec­i­mals. The same lan­guage eval­u­ates it, so the sen­tence 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 ad­dress, as #fig-follow:tau=50,dt=17. Opening that ad­dress re­stores the slid­ers and the curves, so one reader can send an­other a link to a set­ting. Moving the slid­ers back to their start­ing values re­moves the state from the ad­dress, and the Reset button ap­pears only when some­thing has changed.

Always estimated

An ex­plorable draws equa­tions in­stead of mea­sure­ments. Every ex­plorable in Asymptote car­ries the mark Estimated on the figure, what­ever the ar­ti­cle around it is about. The curve shows what the equa­tion gives for the num­bers the reader chose, and the ar­ti­cle says what the equa­tion leaves out. The one above leaves out that a browser does not draw every frame at the same in­ter­val.

The source of this note is in the port­fo­lio’s repos­i­tory under web/notes/asymptote/: expr.js for the lan­guage, chart.js for the markup, figures.mjs for the two fig­ures and their checks, asymptote.test.mjs for the tests. Asymptote’s own source be­longs to RAMTT and is not pub­lished.