Below is a cubic Bézier curve — the kind used in every SVG <path> and CSS cubic-bezier(). It has four control points: two endpoints (the filled circles) and two handles (the hollow circles connected by dashed lines).
Drag any point and watch the curve follow. Toggle Show construction to see de Casteljau's algorithm animate: at each value of the parameter t∈[0,1] the algorithm interpolates between points three times, narrowing down to the single point on the curve.
<!-- Bézier curve interactive explorer -->
<div class="toolbar">
<label class="toggle">
<input type="checkbox" id="showConstruction">
<span>Show construction</span>
</label>
<button id="resetBtn" type="button">Reset</button>
</div>
<canvas id="canvas" width="480" height="340"></canvas>
<p class="hint">Drag any circle to reshape the curve. The filled circles are endpoints; the hollow ones are handles.</p>
/* Layout and visual style for the Bézier demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f4f7fa; color: #222; display: flex; flex-direction: column; align-items: center; padding: .6rem .4rem; }
.toolbar { display: flex; align-items: center; gap: .8rem; margin-bottom: .5rem; flex-wrap: wrap; justify-content: center; }
.toggle { display: flex; align-items: center; gap: .4rem; font-size: .9rem; cursor: pointer; user-select: none; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
canvas { border-radius: 10px; background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.10); touch-action: none; max-width: 100%; }
.hint { font-size: .8rem; color: #555; margin-top: .5rem; text-align: center; }
// Interactive cubic Bézier curve with animated de Casteljau construction
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const showCb = document.getElementById('showConstruction');
const resetBtn = document.getElementById('resetBtn');
// Default control point positions (x,y on the canvas)
const DEFAULT_PTS = [
{ x: 60, y: 270 },
{ x: 140, y: 60 },
{ x: 340, y: 60 },
{ x: 420, y: 270 }
];
let pts = DEFAULT_PTS.map(p => ({ ...p }));
let drag = null;
// Linear interpolation between two points at parameter t
function lerp(a, b, t) {
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
// De Casteljau algorithm — returns the point on the curve at t and all intermediate levels
function deCasteljau(ps, t) {
let layer = ps.slice();
const levels = [layer.map(p => ({ ...p }))];
while (layer.length > 1) {
const next = [];
for (let i = 0; i < layer.length - 1; i++) next.push(lerp(layer[i], layer[i + 1], t));
layer = next;
levels.push(layer.map(p => ({ ...p })));
}
return { point: layer[0], levels };
}
// Draw the cubic Bézier curve using the browser's native bezierCurveTo
function drawCurve() {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
ctx.bezierCurveTo(pts[1].x, pts[1].y, pts[2].x, pts[2].y, pts[3].x, pts[3].y);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
}
// Draw the de Casteljau construction lines and intermediate points for a given t
function drawConstruction(t) {
const { levels } = deCasteljau(pts, t);
const colors = ['#e28a2b', '#2a9d8f', '#e63946'];
for (let l = 1; l < levels.length; l++) {
ctx.beginPath();
const lyr = levels[l];
ctx.moveTo(lyr[0].x, lyr[0].y);
for (let i = 1; i < lyr.length; i++) ctx.lineTo(lyr[i].x, lyr[i].y);
ctx.strokeStyle = colors[l - 1] || '#999';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.setLineDash([]);
lyr.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = colors[l - 1] || '#999';
ctx.fill();
});
}
}
// Draw the dashed lines connecting endpoints to their handles
function drawSkeleton() {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
ctx.lineTo(pts[1].x, pts[1].y);
ctx.moveTo(pts[2].x, pts[2].y);
ctx.lineTo(pts[3].x, pts[3].y);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1.2;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
// Draw the four control-point handles (filled=endpoint, hollow=handle)
function drawHandles() {
pts.forEach((p, i) => {
ctx.beginPath();
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
const isEndpoint = i === 0 || i === 3;
if (isEndpoint) {
ctx.fillStyle = '#1d3557';
ctx.fill();
} else {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
});
}
let animT = 0;
let animDir = 1;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawSkeleton();
drawCurve();
if (showCb.checked) {
drawConstruction(animT);
animT += 0.008 * animDir;
if (animT >= 1) { animT = 1; animDir = -1; }
if (animT <= 0) { animT = 0; animDir = 1; }
}
drawHandles();
}
function hitTest(px, py) {
for (let i = 0; i < pts.length; i++) {
const dx = pts[i].x - px, dy = pts[i].y - py;
if (Math.sqrt(dx * dx + dy * dy) < 14) return i;
}
return -1;
}
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return {
x: (src.clientX - r.left) * (canvas.width / r.width),
y: (src.clientY - r.top) * (canvas.height / r.height)
};
}
canvas.addEventListener('mousedown', e => { const p = getPos(e); drag = hitTest(p.x, p.y); });
canvas.addEventListener('touchstart', e => { e.preventDefault(); const p = getPos(e); drag = hitTest(p.x, p.y); }, { passive: false });
function onMove(e) {
if (drag < 0 || drag === null) return;
if (e.cancelable) e.preventDefault();
const p = getPos(e);
pts[drag].x = Math.max(0, Math.min(canvas.width, p.x));
pts[drag].y = Math.max(0, Math.min(canvas.height, p.y));
}
canvas.addEventListener('mousemove', onMove);
canvas.addEventListener('touchmove', onMove, { passive: false });
canvas.addEventListener('mouseup', () => { drag = null; });
canvas.addEventListener('touchend', () => { drag = null; });
resetBtn.addEventListener('click', () => {
pts = DEFAULT_PTS.map(p => ({ ...p }));
});
// Animation loop — redraws every frame; animates t when construction is shown
(function loop() { draw(); requestAnimationFrame(loop); })();
<!-- Bézier curve interactive explorer -->
<div class="toolbar">
<label class="toggle">
<input type="checkbox" id="showConstruction">
<span>Show construction</span>
</label>
<button id="resetBtn" type="button">Reset</button>
</div>
<canvas id="canvas" width="480" height="340"></canvas>
<p class="hint">Drag any circle to reshape the curve. The filled circles are endpoints; the hollow ones are handles.</p>
/* Layout and visual style for the Bézier demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f4f7fa; color: #222; display: flex; flex-direction: column; align-items: center; padding: .6rem .4rem; }
.toolbar { display: flex; align-items: center; gap: .8rem; margin-bottom: .5rem; flex-wrap: wrap; justify-content: center; }
.toggle { display: flex; align-items: center; gap: .4rem; font-size: .9rem; cursor: pointer; user-select: none; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
canvas { border-radius: 10px; background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.10); touch-action: none; max-width: 100%; }
.hint { font-size: .8rem; color: #555; margin-top: .5rem; text-align: center; }
// Interactive cubic Bézier curve with animated de Casteljau construction
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const showCb = document.getElementById('showConstruction');
const resetBtn = document.getElementById('resetBtn');
// Default control point positions (x,y on the canvas)
const DEFAULT_PTS = [
{ x: 60, y: 270 },
{ x: 140, y: 60 },
{ x: 340, y: 60 },
{ x: 420, y: 270 }
];
let pts = DEFAULT_PTS.map(p => ({ ...p }));
let drag = null;
// Linear interpolation between two points at parameter t
function lerp(a, b, t) {
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
// De Casteljau algorithm — returns the point on the curve at t and all intermediate levels
function deCasteljau(ps, t) {
let layer = ps.slice();
const levels = [layer.map(p => ({ ...p }))];
while (layer.length > 1) {
const next = [];
for (let i = 0; i < layer.length - 1; i++) next.push(lerp(layer[i], layer[i + 1], t));
layer = next;
levels.push(layer.map(p => ({ ...p })));
}
return { point: layer[0], levels };
}
// Draw the cubic Bézier curve using the browser's native bezierCurveTo
function drawCurve() {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
ctx.bezierCurveTo(pts[1].x, pts[1].y, pts[2].x, pts[2].y, pts[3].x, pts[3].y);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
}
// Draw the de Casteljau construction lines and intermediate points for a given t
function drawConstruction(t) {
const { levels } = deCasteljau(pts, t);
const colors = ['#e28a2b', '#2a9d8f', '#e63946'];
for (let l = 1; l < levels.length; l++) {
ctx.beginPath();
const lyr = levels[l];
ctx.moveTo(lyr[0].x, lyr[0].y);
for (let i = 1; i < lyr.length; i++) ctx.lineTo(lyr[i].x, lyr[i].y);
ctx.strokeStyle = colors[l - 1] || '#999';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.setLineDash([]);
lyr.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = colors[l - 1] || '#999';
ctx.fill();
});
}
}
// Draw the dashed lines connecting endpoints to their handles
function drawSkeleton() {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
ctx.lineTo(pts[1].x, pts[1].y);
ctx.moveTo(pts[2].x, pts[2].y);
ctx.lineTo(pts[3].x, pts[3].y);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1.2;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
// Draw the four control-point handles (filled=endpoint, hollow=handle)
function drawHandles() {
pts.forEach((p, i) => {
ctx.beginPath();
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
const isEndpoint = i === 0 || i === 3;
if (isEndpoint) {
ctx.fillStyle = '#1d3557';
ctx.fill();
} else {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
});
}
let animT = 0;
let animDir = 1;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawSkeleton();
drawCurve();
if (showCb.checked) {
drawConstruction(animT);
animT += 0.008 * animDir;
if (animT >= 1) { animT = 1; animDir = -1; }
if (animT <= 0) { animT = 0; animDir = 1; }
}
drawHandles();
}
function hitTest(px, py) {
for (let i = 0; i < pts.length; i++) {
const dx = pts[i].x - px, dy = pts[i].y - py;
if (Math.sqrt(dx * dx + dy * dy) < 14) return i;
}
return -1;
}
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return {
x: (src.clientX - r.left) * (canvas.width / r.width),
y: (src.clientY - r.top) * (canvas.height / r.height)
};
}
canvas.addEventListener('mousedown', e => { const p = getPos(e); drag = hitTest(p.x, p.y); });
canvas.addEventListener('touchstart', e => { e.preventDefault(); const p = getPos(e); drag = hitTest(p.x, p.y); }, { passive: false });
function onMove(e) {
if (drag < 0 || drag === null) return;
if (e.cancelable) e.preventDefault();
const p = getPos(e);
pts[drag].x = Math.max(0, Math.min(canvas.width, p.x));
pts[drag].y = Math.max(0, Math.min(canvas.height, p.y));
}
canvas.addEventListener('mousemove', onMove);
canvas.addEventListener('touchmove', onMove, { passive: false });
canvas.addEventListener('mouseup', () => { drag = null; });
canvas.addEventListener('touchend', () => { drag = null; });
resetBtn.addEventListener('click', () => {
pts = DEFAULT_PTS.map(p => ({ ...p }));
});
// Animation loop — redraws every frame; animates t when construction is shown
(function loop() { draw(); requestAnimationFrame(loop); })();
The key insight: no matter where you drag the handles, the curve always stays smooth, always passes through the two endpoints, and always leaves them in the direction of the handle. That is not a coincidence — it is a theorem baked into the formula.
Comments
Loading comments...