How it works
Figure Lab
Claude never draws a figure. It writes a short JSON recipe of sliders and formulas, and a small engine in the page turns that recipe into live Plotly plots. Below are the six ideas that make that split work, each with the lines of code that carry it.
- Interface design
- Declarative specs
- Plotly & math.js
- LLM tool use
- No backend
From a sentence to a figure
- Describe“a cone inscribed in a sphere, with a slider for its height”
- WriteClaude answers with a recipe: sliders, formulas, objects, readouts
- Samplethe engine evaluates each formula on a grid of points
- DrawPlotly redraws every time a slider moves
- Sharethe recipe goes into a link or a standalone HTML file
A figure is a recipe, not a picture
The JSON format everything else is built on
Every figure, whether one of the eight examples or one Claude just generated, is a plain object with the same parts: params (the sliders), defs (named quantities computed from them), panels of objects to draw, readouts and notes. Every number inside can be a formula string instead. Here is the start of the drilled-sphere example:
title:"Drilled sphere",
subtitle:"A ball of radius R with a cylindrical hole through its center. Change R and the volume left stays the same.",
params:[
{id:"R", label:"Sphere radius R", min:2, max:8, step:0.05, value:5},
{id:"L", label:"Hole length 2H", min:0.5, max:"2*R-0.05", step:0.05, value:8},
{id:"s", label:"Slice height", min:-1, max:1, step:0.01, value:0, display:"z = {z0:2}"},
{id:"ghost", label:"Show the material drilled out", type:"toggle", value:0}
],
defs:{ H:"min(L/2, R*0.999)", r:"sqrt(R^2 - H^2)", z0:"s*H", rho:"sqrt(R^2 - z0^2)" },
panels:[
{ kind:"3d", view:{ x:["-R-0.5","R+0.5"], y:["-R-0.5","R+0.5"], z:["-R-0.5","R+0.5"], camera:{x:1.55,y:1.35,z:0.95} },
objects:[
{type:"parametric_surface", name:"Sphere surface that's left", color:"accent", opacity:0.45, u:[0,"2*pi"], v:["-H","H"], n:[72,40],
x:"sqrt(R^2-v^2)*cos(u)", y:"sqrt(R^2-v^2)*sin(u)", z:"v"},
{type:"parametric_surface", name:"Wall of the hole", color:"gold", opacity:0.85, u:[0,"2*pi"], v:["-H","H"], n:[72,8],
x:"r*cos(u)", y:"r*sin(u)", z:"v"},
{type:"curve", name:"Rims", color:"ink", width:4, t:[0,"2*pi"], n:120, x:"r*cos(t)", y:"r*sin(t)", z:"H"},- Slider bounds can be formulas too. The hole length tops out at
2*R-0.05, so it can never be longer than the ball is wide, whatever R is. - Each
defcan use the sliders and the defs before it.Handrare written once and reused by surfaces, curves and readouts. - A sphere with the poles cut off is just a parametric surface whose
vrange is[-H, H]. Moving a slider changes the range, and the shape follows.
Recipes written by a person, or by a model, are often slightly off. normalize fills in what's missing before anything is drawn, so a figure with one 3D panel can skip the panels wrapper entirely.
function normalize(spec){
if(!spec || typeof spec!=='object') throw new Error('The recipe must be a JSON object');
var s=JSON.parse(JSON.stringify(spec));
if(!Array.isArray(s.panels)){
s.panels=[{kind:s.kind||'3d', title:s.panelTitle, view:s.view||{}, objects:s.objects||[]}];
}
s.panels.forEach(function(p){
p.kind = (String(p.kind||'3d').toLowerCase().indexOf('2')===0) ? '2d' : '3d';
p.view = p.view||{}; p.objects = Array.isArray(p.objects)?p.objects:[];
});
s.params = Array.isArray(s.params)?s.params:[];
s.params.forEach(function(p){
if(!p.id) throw new Error('Every slider needs an "id"');
if(RESERVED.indexOf(p.id)>=0) throw new Error('"'+p.id+'" is reserved for plotting variables; rename that slider');
p.type = p.type||'slider';
if(p.type==='toggle'){ p.value = p.value?1:0; }
else if(p.type==='choice'){ p.options=p.options||[]; if(p.value===undefined && p.options[0]) p.value=p.options[0].value; }
});- A recipe with top-level
objectsis wrapped into a single panel. - Any kind that starts with “2” is a 2D panel; everything else is 3D. “2D”, “2d” and “2d-plot” all work.
- The one hard rule: a slider can't be called
x,t,uand so on, because the engine sets those while sampling. The error message says how to fix it.
Because the figure is data, the same object can be edited by hand under “Figure recipe”, sent back to Claude for changes, packed into a link or embedded in a file. None of those features needed a second format.
Formulas as data, in a sandbox
math.js instead of eval
Recipes come from a model or from a shared link, so their formulas are untrusted text. Running them as JavaScript would let a link run any code on the page. Instead each string is parsed by math.js, whose expression language can only reach math functions. The engine adds three of its own and compiles each formula once.
M.import({
Sum: Sum,
clamp: function(v,a,b){ return Math.min(b,Math.max(a,v)); },
heaviside: function(v){ return v<0?0:1; }
}, {override:true});
var CC = new Map();
function comp(expr){
var key=String(expr), c=CC.get(key);
if(!c){ c=M.compile(key); CC.set(key,c); }
return c;
}
function ev(expr, scope){
if(typeof expr==='number') return expr;
if(typeof expr==='boolean') return expr?1:0;
if(expr==null || expr==='') return NaN;
return num(comp(expr).evaluate(scope));
}
function fnOf(expr){
if(typeof expr==='number'||typeof expr==='boolean'){ var v=num(expr); return function(){return v;}; }
var c=comp(expr); return function(s){ return num(c.evaluate(s)); };
}- The only additions to the language:
Sumfor finite sums,clampandheaviside. Nothing that touches the page. - Compiling is slow and evaluating is fast. A surface evaluates the same string thousands of times per frame, so compiled formulas are cached by their text.
- Everything comes back as a plain number. The math instance is created with
predictable:true(line 282), sosqrtof a negative givesNaNrather than a complex number, and aNaNlater becomes a gap in the plot.
function buildScope(spec, values){
var s={}, k;
for(k in values) s[k]=values[k];
var defs=spec.defs;
for(k in defs){
var key=k.trim();
if(/^[A-Za-z_]\w*\s*\(.*\)$/.test(key)) comp(key+' = '+defs[k]).evaluate(s); // function, e.g. "f(x)": "x^2"
else s[key]=ev(defs[k], s);
}
return s;
}- A def named like
f(x)becomes a real math.js function, usable in every formula after it. - Other defs are evaluated in order into one scope object. That scope is what every object, readout and label template sees.
One render function for everything
Sample, draw, and keep going when one part fails
Each object type has a small builder that turns a formula into numbers Plotly can draw. A surface z = f(x, y) is a grid: set x and y in the scope, evaluate, repeat.
surface: function(o, sc, cx){
var X=rng(o.x,sc,cx.vx||[-5,5]), Y=rng(o.y,sc,cx.vy||[-5,5]), n=pair(o.n,56,56);
var nx=cap(n[0],2,160), ny=cap(n[1],2,160), f=fnOf(o.z), xs=lin(X[0],X[1],nx), ys=lin(Y[0],Y[1],ny);
var zr=cx.vz, pad=zr?(zr[1]-zr[0])*0.02:0;
var Z=ys.map(function(y){ sc.y=y; return xs.map(function(x){ sc.x=x; var z=f(sc); if(zr && (z<zr[0]-pad||z>zr[1]+pad)) return null; return nz(z); }); });
var t=surfStyle(o,cx.c,cx.name); t.x=xs; t.y=ys; t.z=Z; return [t];
},- The grid size is capped at 160 × 160 whatever the recipe asks for, so a careless recipe can't freeze the tab.
- Points that fall outside the view's z range become
null, which Plotly leaves as holes. Without this, a surface like 1/r would stretch the axes to infinity.
Moving a slider only updates a value and asks for a frame. render then rebuilds the scope and every trace from scratch and hands them to Plotly.react, which diffs against what is on screen. Animation is the same thing: the play button moves one value per frame and calls render.
plots.forEach(function(pl, pi){
var p=pl.panel, B=p.kind==='3d'?B3:B2, traces=[], ann=[], nLeg=0, vx=null, vy=null, vz=null;
try{ vx=rng(p.view.x,scope,null); vy=rng(p.view.y,scope,null); vz=rng(p.view.z,scope,null); }catch(e){ errs.push({where:'panel '+(pi+1)+' view', msg:e.message}); }
if(p.title) pl.wrap.querySelector('.fl-ptitle').textContent=tpl(p.title,scope);
p.objects.forEach(function(o, oi){
var where=(o.name||o.type||'object')+' (panel '+(pi+1)+', object '+(oi+1)+')';
try{
if(!truthy(o.show, scope)) return;
var b=B[o.type];
if(!b) throw new Error('unknown type "'+o.type+'" for a '+p.kind+' panel. Allowed: '+Object.keys(B).join(', '));
var sc=Object.assign({}, scope);
var cx={c:color(o.color, oi), name:o.name?tpl(o.name,scope):'', vx:vx, vy:vy, vz:vz, ann:ann};
var tr=b(o, sc, cx);
tr.forEach(function(t){ if(t.showlegend) nLeg++; if(!t.name) t.showlegend=false; t.legendgroup=t.name||undefined; });
traces.push.apply(traces, tr);
}catch(e){ errs.push({where:where, msg:e.message}); }
});
var lay; try{ lay=layoutFor(p, scope, th, rev, nLeg); }catch(e){ errs.push({where:'panel '+(pi+1)+' view', msg:e.message}); lay={}; }
if(ann.length) lay.annotations=ann;
Plotly.react(pl.div, traces, lay, {responsive:true, displaylogo:false, scrollZoom:p.kind==='2d',- The
showfield is a formula too, which is how a toggle hides the “drilled out” caps. - Each object gets its own copy of the scope, since builders overwrite
x,y,twhile sampling. - An object that throws is skipped and its error is collected. The rest of the figure still draws, and the first four errors are shown above it in plain words.
Plotly.reactupdates the plot in place. The layout carries a fixeduirevisionper figure, so the 3D camera stays where you rotated it while the sliders move.
A layout that reorders itself
The figure first on a phone, and colours that follow the theme
On a wide screen the figure fills the left and a sticky sidebar holds the title, sliders, readouts and notes. On a phone that sidebar would end up below both plots, far from the figure it controls. The fix is CSS only: dissolve the two columns and give each card a position in one list.
/* stage */
.stage{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;align-items:start;padding-bottom:28px}
@media (max-width:980px){.stage{grid-template-columns:1fr}}
.side{display:grid;gap:18px;position:sticky;top:76px}
@media (max-width:980px){
/* phones and tablets: title, figure, controls, readouts, companion plot, notes */
.stage{display:flex;flex-direction:column;align-items:stretch;gap:14px}
.side,#panels{display:contents}
.side>.card:first-child{order:0}
#panels>.fl-err{order:1}
#panels>.fl-panel{order:1}
#ctlCard{order:2} #roCard{order:3}
#panels>.fl-panel~.fl-panel{order:4}
#notesCard{order:5} .side>.actions{order:6}
.fl-plot{height:clamp(320px,52vh,520px)}
}- Two columns: the plot takes what's left, the sidebar a fixed 330px.
minmax(0,1fr)lets Plotly shrink instead of pushing the page sideways. display:contentsremoves the sidebar and panel wrappers from layout, so their children become items of one flex column.- The order on a phone: title, main figure, sliders, readouts, then the companion 2D plot and the notes. The sliders sit right under the plot they move.
Recipes name colours by role (accent, gold, teal…), not by hex code. The engine reads the page's CSS variables at render time, so the same recipe looks right in light and dark mode, and switching theme just calls render again.
function color(c, i){
if(!c) c=AUTO[i%AUTO.length];
if(NAMED.indexOf(c)>=0) return cssVar('--'+c) || '#888888';
return c;
}
function rgba(c, a){
var h=toHex(c); if(h[0]!=='#') return c;
var n=parseInt(h.slice(1),16);
return 'rgba('+(n>>16&255)+','+(n>>8&255)+','+(n&255)+','+a+')';
}
function theme(){
return {ink:cssVar('--ink'), muted:cssVar('--muted'), rule:cssVar('--rule'), surface:cssVar('--surface'), bg:cssVar('--bg'),
sans:cssVar('--sans')||'sans-serif', mono:cssVar('--mono')||'monospace'};
}
function gradient(){ return [[0,color('accent')],[0.5,color('teal')],[1,color('gold')]]; }- A named colour is looked up as a CSS custom property, such as
--teal, on every render. Any other CSS colour is passed through. - Axis text, grid lines and fonts come from the same variables, so Plotly's chrome matches the rest of the page.
Claude writes the recipe, then fixes it
Forced tool use and one repair round
The system prompt is the recipe manual: every object type, the expression syntax, guidelines such as “keep total grid points per surface under about 12,000”, and one worked example. The request forces a single tool call, show_figure, so the answer is always a JSON object and never prose with a code block in it.
async function generate(refine){
var text=$('prompt').value.trim();
if(!text){ $('prompt').focus(); setStatus(refine?'Describe the change you want.':'Describe a figure first.',true); return; }
if(!canGenerate()){ openSettings(); return; }
var msg = refine && current
? 'Here is the recipe of the figure on screen:\n```json\n'+JSON.stringify(current)+'\n```\nChange it as follows, keeping everything else that still makes sense: '+text
: 'Make an interactive figure: '+text;
var messages=[{role:'user',content:msg}];
setBusy(true);
try{
var res=await callClaude(messages), spec=res.block.input, problems=null;
try{ load(spec,{source:'generated'}); problems=engine.errors.length?engine.errors:null; }
catch(e){ problems=[{where:'recipe',msg:e.message}]; }
if(problems){
setStatus('<span class="spin"></span>Fixing a problem in the figure…');
messages.push({role:'assistant',content:res.content});
messages.push({role:'user',content:[{type:'tool_result',tool_use_id:res.block.id,is_error:true,
content:'The renderer reported these problems:\n'+problems.map(function(p){return '- '+p.where+': '+p.msg}).join('\n')+'\nCall show_figure again with a corrected, complete recipe.'}]});
var res2=await callClaude(messages);
load(res2.block.input,{source:'generated'});
}- “Change this figure” sends the current recipe back with the requested change. Editing is the same call as generating, with more context.
- The recipe is drawn immediately. Any problems the renderer collected in section 03 become a list of errors.
- If something failed, those exact errors go back to Claude as a failed tool result, and it is asked for a corrected recipe. The renderer is the test; the model reads its output.
Generation needs an API key. By default each visitor pastes their own, which stays in their browser's storage and is sent only to Anthropic. The optional proxy, a 58-line Supabase function, lets visitors use William's key behind a passcode instead. It forwards only what Figure Lab needs.
if (!ORIGINS.includes(origin)) return reply(403, { error: { message: "This site isn't allowed to use the proxy" } });
if (PASSCODE && req.headers.get("x-lab-passcode") !== PASSCODE) return reply(401, { error: { message: "Wrong passcode" } });
if (!KEY) return reply(500, { error: { message: "ANTHROPIC_API_KEY is not set on the server" } });
const text = await req.text();
if (text.length > MAX_BODY) return reply(413, { error: { message: "Request too large" } });
let body: Record<string, unknown>;
try { body = JSON.parse(text); } catch { return reply(400, { error: { message: "Bad JSON" } }); }
// Only forward the fields Figure Lab uses, with a capped size and an allowed model.
const payload = {
model: MODELS.includes(String(body.model)) ? body.model : MODELS[0],
max_tokens: Math.min(Number(body.max_tokens) || MAX_TOKENS, MAX_TOKENS),
system: body.system,
tools: body.tools,
tool_choice: body.tool_choice,
messages: body.messages,
};- Requests must come from an allowed site and carry the passcode; anything else is turned away before a request reaches Anthropic.
- The body is rebuilt field by field: the model must be on a short list and
max_tokensis capped at 12,000, so a passcode holder can't use the key for anything larger.
Read further
Where everything lives
- index.html the whole app: styles, the figure engine (
<script id="engine">), the eight examples, the Claude prompt, sharing and export - proxy.ts the optional passcode proxy, a Supabase Edge Function