How to Plot 2D Mathematical Curves with JavaScript Canvas in 2025
Prerequisites and Setup Checklist
What You Need Before Starting
- [ ] Node.js 18+ installed (only needed if you want a local dev server; otherwise a browser suffices)
- [ ] A modern browser — Chrome 120+, Firefox 121+, or Safari 17+ all support the Canvas 2D API features used here
- [ ] Basic JavaScript knowledge — ES2020 syntax, arrow functions,
requestAnimationFrame - [ ] A text editor — VS Code with the Live Server extension makes iteration fast
- [ ] Optional: math.js 12+ if you want to parse user-typed expressions (
npm install mathjs) - [ ] Optional: Plotly.js or D3.js as drop-in alternatives when you need axis labels and interactivity out of the box
Project Structure and File Setup
Create a minimal project folder:
curve-explorer/
index.html
curves.js
style.css
Your index.html skeleton:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Curve Explorer</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<canvas id="curveCanvas" width="800" height="600"></canvas>
<div id="controls"></div>
<script type="module" src="curves.js"></script>
</body>
</html>
Browser and Node.js Compatibility Notes
The Canvas 2D API (CanvasRenderingContext2D) is available in every major browser without a polyfill. In Node.js, use the canvas npm package (npm install canvas) which mirrors the browser API almost exactly — useful for server-side rendering or test suites. All code in this article works in both environments.
Estimated time: 90 minutes to work through every step and have a live interactive curve explorer running.
Step 1: Understanding Curve Equation Types Before You Code
Before writing a single line of rendering code, you need to know which form your equation takes, because the form dictates the renderer. Getting this wrong means rewriting your loop from scratch.
Algebraic Curves: Polynomial Equations in x and y
An algebraic curve satisfies a polynomial equation P(x, y) = 0. The degree of the polynomial gives you the classic taxonomy used on reference databases like 2dcurves.com, which catalogs 939 named curves:
| Degree | Category | Example | Easiest Rendering Method |
|--------|----------|---------|-------------------------|
| 1st | Line | y = mx + b | Direct lineTo |
| 2nd | Conic | x²/a² + y²/b² = 1 | Parametric loop |
| 3rd | Cubic | y² = x³ + ax + b | Marching squares or parametric |
| 4th | Quartic | (x²+y²)² = a²(x²−y²) | Marching squares |
| 6th | Sextic | Various lemniscates | Marching squares |
Transcendental Curves: Spirals, Trigonometric, and Exponential Forms
Transcendental curves can't be expressed as a polynomial — they involve sin, cos, exp, log, or similar. Examples: Archimedean spiral (r = aθ in polar), the catenary (y = a·cosh(x/a)), and Lissajous figures. These almost always have a clean parametric form, making them trivial to render with a for loop.
Parametric vs. Implicit vs. Explicit Representations
- Explicit y = f(x): trivial to render, but fails for vertical tangents and closed curves.
- Parametric x(t), y(t): a single loop over t from tMin to tMax draws any curve regardless of topology. This is your first-choice renderer.
- Implicit F(x, y) = 0: the equation can't be solved cleanly for one variable. You need the marching squares algorithm to trace the zero-contour on a grid.
Note: When you look up a curve on a reference site, always check whether a parametric form is listed first. If it exists, use it — implicit renderers are 5–10× slower and require tuning the grid resolution.
Step 2: Rendering Parametric Curves on HTML5 Canvas
Parametric rendering is the backbone of curve visualization. The core insight is that the Canvas 2D API is just a stateful path builder — you feed it (x, y) screen coordinates, and it draws lines between them.
Setting Up the Coordinate System and Scale Transform
Canvas coordinates have (0,0) at the top-left with y increasing downward. Mathematical coordinates center the origin and have y increasing upward. The setTransform call handles this once:
// curves.js
function setupMathCoords(ctx, canvas, xRange, yRange) {
const scaleX = canvas.width / (xRange[1] - xRange[0]);
const scaleY = canvas.height / (yRange[1] - yRange[0]);
const scale = Math.min(scaleX, scaleY);
ctx.setTransform(
scale, 0,
0, -scale, // negative: flip y-axis
canvas.width / 2,
canvas.height / 2
);
}
Looping Over the Parameter t to Draw Points
function drawParametricCurve(ctx, xFn, yFn, tMin, tMax, steps = 1000) {
ctx.beginPath();
const dt = (tMax - tMin) / steps;
for (let i = 0; i <= steps; i++) {
const t = tMin + i * dt;
const x = xFn(t);
const y = yFn(t);
if (!isFinite(x) || !isFinite(y)) {
ctx.moveTo(xFn(t + dt * 0.01), yFn(t + dt * 0.01));
continue;
}
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
The isFinite guard is critical — curves like the tractrix or cissoid have asymptotes where the function blows up, and a single NaN will silently break the entire path.
Rendering a Lissajous Curve as a Working Example
A Lissajous figure: x = A·sin(a·t + δ), y = B·sin(b·t). Changing the frequency ratio a:b gives different figures — 1:1 is an ellipse, 3:2 is a figure-eight variant.
const canvas = document.getElementById('curveCanvas');
const ctx = canvas.getContext('2d');
// Parameters
let A = 100, B = 100, freqA = 3, freqB = 2, delta = Math.PI / 4;
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setupMathCoords(ctx, canvas, [-150, 150], [-150, 150]);
ctx.strokeStyle = '#00d4ff';
ctx.lineWidth = 1.5 / (canvas.width / 300); // compensate for transform scale
drawParametricCurve(
ctx,
t => A * Math.sin(freqA * t + delta),
t => B * Math.sin(freqB * t),
0, 2 * Math.PI,
2000
);
}
render();
Note: Set
lineWidthafter applying the transform, or compensate for the scale factor as shown above. A lineWidth of 2 in math-space will look enormous after scaling.
Step 3: Plotting Algebraic (Implicit) Curves with Marching Squares
Some of the most beautiful curves — Cassini ovals, lemniscates, foliums — only have compact implicit forms. You can't loop over t cleanly; instead, you sample the function on a grid and trace where it changes sign.
What Is the Marching Squares Algorithm and When to Use It
Marching squares divides the canvas into a grid of cells. For each cell, it evaluates F(x, y) at the four corners. If the sign changes between adjacent corners, the zero-crossing lies somewhere on that edge — you interpolate its position. Connecting these edge midpoints gives you the contour.
Use it when: no parametric form exists, the curve self-intersects in complex ways, or you're parsing a user-entered equation.
Implementing a Grid Sampler for F(x,y) = 0
function marchingSquares(ctx, fn, xMin, xMax, yMin, yMax, cols = 200, rows = 200) {
const dx = (xMax - xMin) / cols;
const dy = (yMax - yMin) / rows;
// Pre-sample the grid
const grid = [];
for (let j = 0; j <= rows; j++) {
grid[j] = [];
for (let i = 0; i <= cols; i++) {
const x = xMin + i * dx;
const y = yMin + j * dy;
grid[j][i] = fn(x, y);
}
}
ctx.beginPath();
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
const v00 = grid[j][i], v10 = grid[j][i+1];
const v01 = grid[j+1][i], v11 = grid[j+1][i+1];
// Interpolate zero-crossing on each edge
function interp(a, b, va, vb) {
if ((va < 0) === (vb < 0)) return null;
return a + (b - a) * (-va / (vb - va));
}
const x0 = xMin + i * dx, x1 = x0 + dx;
const y0 = yMin + j * dy, y1 = y0 + dy;
const top = interp(x0, x1, v00, v10);
const bottom = interp(x0, x1, v01, v11);
const left = interp(y0, y1, v00, v01);
const right = interp(y0, y1, v10, v11);
const pts = [];
if (top !== null) pts.push([top, y0]);
if (bottom !== null) pts.push([bottom, y1]);
if (left !== null) pts.push([x0, left]);
if (right !== null) pts.push([x1, right]);
if (pts.length >= 2) {
ctx.moveTo(pts[0][0], pts[0][1]);
ctx.lineTo(pts[1][0], pts[1][1]);
}
}
}
ctx.stroke();
}
Example: Drawing a Cassini Oval and a Lemniscate of Bernoulli
The Cassini oval is defined by: (x² + y²)² − 2a²(x² − y²) = b⁴
When b = a you get the lemniscate of Bernoulli.
const a = 1.2, b = 1.5;
function cassiniOval(x, y) {
return Math.pow(x*x + y*y, 2) - 2*a*a*(x*x - y*y) - Math.pow(b, 4);
}
// After setupMathCoords(ctx, canvas, [-3, 3], [-3, 3])
marchingSquares(ctx, cassiniOval, -3, 3, -3, 3, 300, 300);
Set cols and rows to 300 for smooth output. On a 2021 MacBook Pro, rendering at 300×300 takes ~8ms — imperceptible. Drop to 150×150 for real-time slider interaction.
Step 4: Handling Derived Curves — Evolutes, Pedals, and Roulettes
The 2dcurves.com taxonomy lists 17 derivation types including evolutes, roulettes, pedals, and cissoids. These are curves computed from other curves. Implementing them as composable functions pays off when you want to explore the relationship between a base curve and its derivative.
Computing the Evolute of a Parametric Curve Programmatically
The evolute is the locus of centers of curvature. Given x(t) and y(t), the evolute coordinates are:
X = x - y' * (x'² + y'²) / (x'·y'' - y'·x'')
Y = y + x' * (x'² + y'²) / (x'·y'' - y'·x'')
We estimate derivatives using finite differences:
function evolute(xFn, yFn, tMin, tMax, steps = 1000) {
const dt = (tMax - tMin) / steps;
const pts = [];
for (let i = 1; i < steps - 1; i++) {
const t = tMin + i * dt;
const xp = (xFn(t + dt) - xFn(t - dt)) / (2 * dt);
const yp = (yFn(t + dt) - yFn(t - dt)) / (2 * dt);
const xpp = (xFn(t + dt) - 2*xFn(t) + xFn(t - dt)) / (dt * dt);
const ypp = (yFn(t + dt) - 2*yFn(t) + yFn(t - dt)) / (dt * dt);
const denom = xp * ypp - yp * xpp;
if (Math.abs(denom) < 1e-10) continue;
const kappa = (xp*xp + yp*yp) / denom;
pts.push([xFn(t) - yp * kappa, yFn(t) + xp * kappa]);
}
return pts;
}
Generating a Roulette (Cycloid, Epicycloid, Hypocycloid)
An epicycloid traces a point on a circle of radius r rolling outside a circle of radius R:
function epicycloidX(R, r, t) {
return (R + r) * Math.cos(t) - r * Math.cos((R + r) * t / r);
}
function epicycloidY(R, r, t) {
return (R + r) * Math.sin(t) - r * Math.sin((R + r) * t / r);
}
// Animated construction using requestAnimationFrame
let tCurrent = 0;
const tMax = 2 * Math.PI * (r / gcd(R, r)); // full period
function gcd(a, b) { return b < 0.001 ? a : gcd(b, a % b); }
function animateEpicycloid() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setupMathCoords(ctx, canvas, [-200, 200], [-200, 200]);
const R = 80, r = 30;
ctx.strokeStyle = '#ff6b35';
ctx.lineWidth = 0.5;
drawParametricCurve(ctx, t => epicycloidX(R, r, t), t => epicycloidY(R, r, t), 0, tCurrent, Math.ceil(tCurrent / 0.005));
tCurrent += 0.04;
if (tCurrent < 2 * Math.PI * (R / r)) requestAnimationFrame(animateEpicycloid);
}
requestAnimationFrame(animateEpicycloid);
Chaining Curve Derivations in Code
Because evolute returns an array of [x, y] points, you can chain: compute the evolute of an ellipse, then treat that as a polyline input to compute its evolute. Each derivation is a pure function that maps (xFn, yFn) → [[x,y], ...], making composition natural.
Step 5: Building an Interactive Curve Explorer UI
A static curve renderer is useful, but sliders that let you scrub through parameter space turn it into a learning tool. The goal here is sub-16ms redraws on slider movement.
Adding Parameter Sliders with the Range Input Element
function addSlider(container, label, min, max, value, step, onChange) {
const wrap = document.createElement('div');
const lbl = document.createElement('label');
lbl.textContent = `${label}: `;
const val = document.createElement('span');
val.textContent = value;
const input = document.createElement('input');
Object.assign(input, { type: 'range', min, max, value, step });
input.addEventListener('input', () => {
val.textContent = input.value;
onChange(parseFloat(input.value));
});
wrap.append(lbl, val, input);
container.appendChild(wrap);
return input;
}
const controls = document.getElementById('controls');
let params = { A: 100, B: 100, freqA: 3, freqB: 2, delta: Math.PI / 4 };
// Debounce to avoid thrashing on fast drag
let rafId = null;
function scheduleRender() {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => { render(); rafId = null; });
}
addSlider(controls, 'freq A', 1, 10, 3, 1, v => { params.freqA = v; scheduleRender(); });
addSlider(controls, 'freq B', 1, 10, 2, 1, v => { params.freqB = v; scheduleRender(); });
addSlider(controls, 'delta (π×)', 0, 2, 0.25, 0.05, v => { params.delta = v * Math.PI; scheduleRender(); });
The scheduleRender debounce using requestAnimationFrame is key — it collapses multiple synchronous slider events into a single repaint, keeping you at 60fps.
Animating Curve Construction with requestAnimationFrame
The epicycloid animation in Step 4 shows the pattern: increment a global tCurrent each frame, redraw only up to that t value, and use requestAnimationFrame to schedule the next frame. This gives a smooth "drawing" animation that reveals the curve over ~2 seconds.
Exporting the Canvas to SVG or PNG
// PNG export
function exportPNG(canvas) {
const link = document.createElement('a');
link.download = 'curve.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
// Basic SVG path serializer for parametric curves
function parametricToSVGPath(xFn, yFn, tMin, tMax, steps, scaleX, scaleY, offsetX, offsetY) {
let d = '';
const dt = (tMax - tMin) / steps;
for (let i = 0; i <= steps; i++) {
const t = tMin + i * dt;
const sx = xFn(t) * scaleX + offsetX;
const sy = -yFn(t) * scaleY + offsetY; // flip y for SVG
d += (i === 0 ? 'M' : 'L') + `${sx.toFixed(2)},${sy.toFixed(2)} `;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><path d="${d}" fill="none" stroke="black" stroke-width="1.5"/></svg>`;
}
document.getElementById('exportBtn').addEventListener('click', () => exportPNG(canvas));
For the SVG exporter, you supply the same scale and offset you used in setupMathCoords — the math is identical, just serialized as a string instead of draw calls.
Common Issues and Fixes
Fix: Curve Appears Mirrored or Upside Down
Cause: You forgot to negate the y-scale in the canvas transform, so mathematical y (up) maps to canvas y (down).
Fix: In setTransform, the second scale argument must be negative:
ctx.setTransform(scale, 0, 0, -scale, canvas.width / 2, canvas.height / 2);
// ^^^^^^ this negative sign flips y
If the curve is mirrored left-right, you've also negated the x scale by mistake. The x scale should always be positive.
Fix: Jagged Lines at Low Step Count or High Curvature
Cause: Fixed step size dt is too coarse near high-curvature regions (cusps, tight loops).
Fix: Use an adaptive step that estimates arc length and splits when too long:
function adaptiveSteps(xFn, yFn, tMin, tMax, maxSegLen = 2) {
const pts = [[xFn(tMin), yFn(tMin)]];
let t = tMin;
const baseDt = (tMax - tMin) / 500;
while (t < tMax) {
let dt = baseDt;
let nx = xFn(t + dt), ny = yFn(t + dt);
let dist = Math.hypot(nx - pts.at(-1)[0], ny - pts.at(-1)[1]);
while (dist > maxSegLen && dt > 1e-6) {
dt /= 2;
nx = xFn(t + dt); ny = yFn(t + dt);
dist = Math.hypot(nx - pts.at(-1)[0], ny - pts.at(-1)[1]);
}
t += dt;
pts.push([nx, ny]);
}
return pts;
}
Fix: Canvas Blurry on Retina / High-DPI Screens
Cause: The canvas bitmap is rendered at 1× but stretched by CSS to 2× on HiDPI displays.
Fix: Scale the canvas pixel dimensions by devicePixelRatio and then apply an inverse CSS scale:
function setupHiDPI(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr); // scale drawing operations to match physical pixels
return ctx;
}
// Call before any drawing:
const ctx = setupHiDPI(document.getElementById('curveCanvas'));
Call setupHiDPI once on load. After calling it, all coordinates you pass to drawParametricCurve are in CSS pixels — the DPR scaling is transparent.
Fix: Implicit Curve Renderer Misses Thin Branches
Cause: The marching squares grid cell size is larger than the width of a thin branch, so it straddles the branch without detecting a sign change.
Fix: Decrease grid cell size. Change cols and rows from 100 to 300 or 400. For curves with known thin regions, sample locally with a finer grid:
// Adaptive: run a coarse pass, then refine cells near detected crossings
marchingSquares(ctx, fn, xMin, xMax, yMin, yMax, 400, 400);
At 400×400 you sample 160,000 cells — still under 20ms in modern browsers. If performance is an issue, run the grid computation in a Web Worker and postMessage the line segments back to the main thread for drawing.
FAQ
Q: Which JavaScript library is best for plotting mathematical curves in 2025?
It depends on your use case. Vanilla Canvas 2D (this article's approach) is the right choice when you need full control, zero dependencies, and performance tuning — it's what you'd use in a production math application. Plotly.js is the fastest path to publication-quality charts with axes and tooltips, but it treats curves as discrete datasets, not equations. math.js + Canvas is ideal if you want to parse user-typed expressions like sin(x)*exp(-x) at runtime — math.js compiles the expression to a JS function you pass directly to drawParametricCurve. Embedding Desmos via their API works for demos but locks you into their rendering and rate limits. For most developer use cases in 2025, math.js + vanilla canvas is the sweet spot.
Q: How do I plot a curve defined only by its name from a reference like 2dcurves.com?
The workflow is: (1) find the named curve in the reference's taxonomy, (2) identify whether the listed equation is explicit, parametric, or implicit, (3) pick the matching renderer. For example, looking up "Lemniscate of Bernoulli" on 2dcurves.com gives you both the implicit form (x²+y²)² = 2a²(x²−y²) and the polar form r² = 2a²cos(2θ) — the polar form is actually a disguised parametric: x(θ) = r(θ)cos(θ), y(θ) = r(θ)sin(θ). Always prefer the parametric form when it's available because it's a direct loop, not a grid algorithm. If only the implicit form exists, plug it into marchingSquares as fn.
Q: Can I use WebGL instead of Canvas 2D for better performance with complex curves?
Yes, and it's worth it once you exceed roughly 50,000 points per frame or need multiple curves rendered simultaneously at 60fps. For parametric curves, regl gives you a lightweight WebGL abstraction where you upload the curve points as a Float32Array buffer and draw with gl.LINES. For more complex scenes, Three.js with LineSegments2 (from the three/addons package) supports thick anti-aliased lines in WebGL. The 2D Canvas API tops out around 10,000–30,000 lineTo calls per frame at 60fps on mid-range hardware — beyond that, frame drops become visible. For the curves in this article (parametric with ≤5,000 points, implicit with ≤400×400 grids), Canvas 2D is completely sufficient.
Recommended Tools
- VercelDeploy frontend apps instantly with zero config
- CloudflareFast, secure CDN and DNS for any website