Drag the waypoints on the canvas below. The curve passes through every colored dot — not just near them. Add a new point by clicking an empty area, or remove one by right-clicking it.
<!-- Toolbar + canvas for the Catmull-Rom interactive demo -->
<div class="toolbar">
<button id="btn-tangents" type="button" class="toggle" aria-pressed="false">Show tangents</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="hint-label">Click to add · right-click to remove · drag to move</span>
</div>
<canvas id="canvas" width="560" height="340" aria-label="Interactive Catmull-Rom spline canvas"></canvas>
<div class="status" id="status">Drag a point or click to add one.</div>
/* Styles for the Catmull-Rom demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fa; color: #1d2b3a; }
.toolbar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; padding: .5rem 0 .4rem; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border-radius: 7px;
border: 1px solid #1d3557; background: #1d3557; color: #fff; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.toggle.active { background: #2a7d4f; border-color: #2a7d4f; }
.hint-label { font-size: .82rem; color: #556; margin-left: .25rem; }
canvas { display: block; border-radius: 10px; background: #fff;
border: 1px solid #dde3ea; cursor: crosshair; touch-action: none; max-width: 100%; }
.status { font-size: .88rem; color: #556; margin-top: .4rem; min-height: 1.3em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Catmull-Rom spline interactive demo */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const btnTangents = document.getElementById('btn-tangents');
const btnReset = document.getElementById('btn-reset');
// Default waypoints spread across the canvas
let pts = [
{ x: 80, y: 220 },
{ x: 170, y: 100 },
{ x: 280, y: 250 },
{ x: 380, y: 90 },
{ x: 480, y: 200 }
];
let showTangents = false;
let dragging = null; // index of the point being dragged, or null
const RADIUS = 9; // hit-test + draw radius for control points
const STEPS = 60; // line segments drawn per cubic segment
/* Evaluate the Catmull-Rom cubic at parameter t in [0,1] for four control points */
function catmullRomPoint(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return {
x: 0.5 * ((2*p1.x) + (-p0.x + p2.x)*t + (2*p0.x - 5*p1.x + 4*p2.x - p3.x)*t2 + (-p0.x + 3*p1.x - 3*p2.x + p3.x)*t3),
y: 0.5 * ((2*p1.y) + (-p0.y + p2.y)*t + (2*p0.y - 5*p1.y + 4*p2.y - p3.y)*t2 + (-p0.y + 3*p1.y - 3*p2.y + p3.y)*t3)
};
}
/* Evaluate the derivative of the Catmull-Rom cubic at parameter t */
function catmullRomTangent(p0, p1, p2, p3, t) {
const t2 = t * t;
return {
x: 0.5 * ((-p0.x + p2.x) + 2*(2*p0.x - 5*p1.x + 4*p2.x - p3.x)*t + 3*(-p0.x + 3*p1.x - 3*p2.x + p3.x)*t2),
y: 0.5 * ((-p0.y + p2.y) + 2*(2*p0.y - 5*p1.y + 4*p2.y - p3.y)*t + 3*(-p0.y + 3*p1.y - 3*p2.y + p3.y)*t2)
};
}
/* Add phantom end-points so the curve passes through the first and last real points */
function phantom(pts) {
const n = pts.length;
const p0 = { x: 2*pts[0].x - pts[1].x, y: 2*pts[0].y - pts[1].y };
const pn = { x: 2*pts[n-1].x - pts[n-2].x, y: 2*pts[n-1].y - pts[n-2].y };
return [p0, ...pts, pn];
}
function draw() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
if (pts.length < 2) {
// Not enough points — show a placeholder message
ctx.fillStyle = '#999';
ctx.font = '14px system-ui';
ctx.textAlign = 'center';
ctx.fillText('Add at least 2 points to draw the spline.', W / 2, H / 2);
return;
}
const ext = phantom(pts);
/* Trace the full spline by iterating over all segments */
ctx.beginPath();
ctx.strokeStyle = '#1d6fba';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
let first = true;
for (let i = 0; i < ext.length - 3; i++) {
const p0 = ext[i], p1 = ext[i+1], p2 = ext[i+2], p3 = ext[i+3];
for (let s = 0; s <= STEPS; s++) {
const t = s / STEPS;
const pt = catmullRomPoint(p0, p1, p2, p3, t);
if (first) { ctx.moveTo(pt.x, pt.y); first = false; }
else ctx.lineTo(pt.x, pt.y);
}
}
ctx.stroke();
/* Overlay velocity arrows at t=0, 0.5, 1 for each segment */
if (showTangents) {
ctx.strokeStyle = '#e07c2a';
ctx.lineWidth = 1.4;
for (let i = 0; i < ext.length - 3; i++) {
const p0 = ext[i], p1 = ext[i+1], p2 = ext[i+2], p3 = ext[i+3];
for (const t of [0, 0.5, 1]) {
const pt = catmullRomPoint(p0, p1, p2, p3, t);
const tg = catmullRomTangent(p0, p1, p2, p3, t);
const len = Math.sqrt(tg.x*tg.x + tg.y*tg.y) || 1;
const scale = 28 / len;
ctx.beginPath();
ctx.moveTo(pt.x, pt.y);
ctx.lineTo(pt.x + tg.x*scale, pt.y + tg.y*scale);
ctx.stroke();
// Draw the arrowhead triangle at the tip of each tangent arrow
const ax = pt.x + tg.x*scale, ay = pt.y + tg.y*scale;
const angle = Math.atan2(tg.y, tg.x);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(ax - 6*Math.cos(angle-0.4), ay - 6*Math.sin(angle-0.4));
ctx.lineTo(ax - 6*Math.cos(angle+0.4), ay - 6*Math.sin(angle+0.4));
ctx.closePath();
ctx.fillStyle = '#e07c2a';
ctx.fill();
}
}
}
/* Draw colored numbered control-point dots on top */
const colors = ['#e63946','#2a7d4f','#1d6fba','#9b2dca','#c97b22','#0a7d6e'];
pts.forEach((p, i) => {
ctx.beginPath();
ctx.arc(p.x, p.y, RADIUS, 0, 2*Math.PI);
ctx.fillStyle = colors[i % colors.length];
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// White index number inside each dot
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(i + 1, p.x, p.y);
});
statusEl.textContent = i18nFmt('%n% waypoints — curve passes through all of them.', { n: pts.length });
}
// Return the index of the point under (mx, my), or -1
function hitTest(mx, my) {
for (let i = pts.length - 1; i >= 0; i--) {
const dx = pts[i].x - mx, dy = pts[i].y - my;
if (dx*dx + dy*dy <= (RADIUS + 4)*(RADIUS + 4)) 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) };
}
// Left-click drag an existing point or add a new one
canvas.addEventListener('mousedown', e => {
if (e.button === 2) return;
const pos = getPos(e);
const hit = hitTest(pos.x, pos.y);
if (hit >= 0) { dragging = hit; }
else { pts.push({ x: pos.x, y: pos.y }); draw(); }
});
// Right-click removes the point under the cursor (keep at least 2)
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
const pos = getPos(e);
const hit = hitTest(pos.x, pos.y);
if (hit >= 0 && pts.length > 2) { pts.splice(hit, 1); draw(); }
});
canvas.addEventListener('mousemove', e => {
if (dragging === null) return;
const pos = getPos(e);
pts[dragging] = { x: pos.x, y: pos.y };
draw();
});
canvas.addEventListener('mouseup', () => { dragging = null; });
canvas.addEventListener('mouseleave', () => { dragging = null; });
// Mirror mouse interactions for touch screens
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const pos = getPos(e);
const hit = hitTest(pos.x, pos.y);
if (hit >= 0) dragging = hit;
else { pts.push({ x: pos.x, y: pos.y }); draw(); }
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (dragging === null) return;
const pos = getPos(e);
pts[dragging] = { x: pos.x, y: pos.y };
draw();
}, { passive: false });
canvas.addEventListener('touchend', () => { dragging = null; });
btnTangents.addEventListener('click', () => {
showTangents = !showTangents;
btnTangents.classList.toggle('active', showTangents);
btnTangents.setAttribute('aria-pressed', showTangents);
draw();
});
btnReset.addEventListener('click', () => {
pts = [
{ x: 80, y: 220 },
{ x: 170, y: 100 },
{ x: 280, y: 250 },
{ x: 380, y: 90 },
{ x: 480, y: 200 }
];
showTangents = false;
btnTangents.classList.remove('active');
btnTangents.setAttribute('aria-pressed', false);
draw();
});
draw();
<!-- Toolbar + canvas for the Catmull-Rom interactive demo -->
<div class="toolbar">
<button id="btn-tangents" type="button" class="toggle" aria-pressed="false">Show tangents</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="hint-label">Click to add · right-click to remove · drag to move</span>
</div>
<canvas id="canvas" width="560" height="340" aria-label="Interactive Catmull-Rom spline canvas"></canvas>
<div class="status" id="status">Drag a point or click to add one.</div>
/* Styles for the Catmull-Rom demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fa; color: #1d2b3a; }
.toolbar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; padding: .5rem 0 .4rem; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border-radius: 7px;
border: 1px solid #1d3557; background: #1d3557; color: #fff; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.toggle.active { background: #2a7d4f; border-color: #2a7d4f; }
.hint-label { font-size: .82rem; color: #556; margin-left: .25rem; }
canvas { display: block; border-radius: 10px; background: #fff;
border: 1px solid #dde3ea; cursor: crosshair; touch-action: none; max-width: 100%; }
.status { font-size: .88rem; color: #556; margin-top: .4rem; min-height: 1.3em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Catmull-Rom spline interactive demo */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const btnTangents = document.getElementById('btn-tangents');
const btnReset = document.getElementById('btn-reset');
// Default waypoints spread across the canvas
let pts = [
{ x: 80, y: 220 },
{ x: 170, y: 100 },
{ x: 280, y: 250 },
{ x: 380, y: 90 },
{ x: 480, y: 200 }
];
let showTangents = false;
let dragging = null; // index of the point being dragged, or null
const RADIUS = 9; // hit-test + draw radius for control points
const STEPS = 60; // line segments drawn per cubic segment
/* Evaluate the Catmull-Rom cubic at parameter t in [0,1] for four control points */
function catmullRomPoint(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return {
x: 0.5 * ((2*p1.x) + (-p0.x + p2.x)*t + (2*p0.x - 5*p1.x + 4*p2.x - p3.x)*t2 + (-p0.x + 3*p1.x - 3*p2.x + p3.x)*t3),
y: 0.5 * ((2*p1.y) + (-p0.y + p2.y)*t + (2*p0.y - 5*p1.y + 4*p2.y - p3.y)*t2 + (-p0.y + 3*p1.y - 3*p2.y + p3.y)*t3)
};
}
/* Evaluate the derivative of the Catmull-Rom cubic at parameter t */
function catmullRomTangent(p0, p1, p2, p3, t) {
const t2 = t * t;
return {
x: 0.5 * ((-p0.x + p2.x) + 2*(2*p0.x - 5*p1.x + 4*p2.x - p3.x)*t + 3*(-p0.x + 3*p1.x - 3*p2.x + p3.x)*t2),
y: 0.5 * ((-p0.y + p2.y) + 2*(2*p0.y - 5*p1.y + 4*p2.y - p3.y)*t + 3*(-p0.y + 3*p1.y - 3*p2.y + p3.y)*t2)
};
}
/* Add phantom end-points so the curve passes through the first and last real points */
function phantom(pts) {
const n = pts.length;
const p0 = { x: 2*pts[0].x - pts[1].x, y: 2*pts[0].y - pts[1].y };
const pn = { x: 2*pts[n-1].x - pts[n-2].x, y: 2*pts[n-1].y - pts[n-2].y };
return [p0, ...pts, pn];
}
function draw() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
if (pts.length < 2) {
// Not enough points — show a placeholder message
ctx.fillStyle = '#999';
ctx.font = '14px system-ui';
ctx.textAlign = 'center';
ctx.fillText('Add at least 2 points to draw the spline.', W / 2, H / 2);
return;
}
const ext = phantom(pts);
/* Trace the full spline by iterating over all segments */
ctx.beginPath();
ctx.strokeStyle = '#1d6fba';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
let first = true;
for (let i = 0; i < ext.length - 3; i++) {
const p0 = ext[i], p1 = ext[i+1], p2 = ext[i+2], p3 = ext[i+3];
for (let s = 0; s <= STEPS; s++) {
const t = s / STEPS;
const pt = catmullRomPoint(p0, p1, p2, p3, t);
if (first) { ctx.moveTo(pt.x, pt.y); first = false; }
else ctx.lineTo(pt.x, pt.y);
}
}
ctx.stroke();
/* Overlay velocity arrows at t=0, 0.5, 1 for each segment */
if (showTangents) {
ctx.strokeStyle = '#e07c2a';
ctx.lineWidth = 1.4;
for (let i = 0; i < ext.length - 3; i++) {
const p0 = ext[i], p1 = ext[i+1], p2 = ext[i+2], p3 = ext[i+3];
for (const t of [0, 0.5, 1]) {
const pt = catmullRomPoint(p0, p1, p2, p3, t);
const tg = catmullRomTangent(p0, p1, p2, p3, t);
const len = Math.sqrt(tg.x*tg.x + tg.y*tg.y) || 1;
const scale = 28 / len;
ctx.beginPath();
ctx.moveTo(pt.x, pt.y);
ctx.lineTo(pt.x + tg.x*scale, pt.y + tg.y*scale);
ctx.stroke();
// Draw the arrowhead triangle at the tip of each tangent arrow
const ax = pt.x + tg.x*scale, ay = pt.y + tg.y*scale;
const angle = Math.atan2(tg.y, tg.x);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(ax - 6*Math.cos(angle-0.4), ay - 6*Math.sin(angle-0.4));
ctx.lineTo(ax - 6*Math.cos(angle+0.4), ay - 6*Math.sin(angle+0.4));
ctx.closePath();
ctx.fillStyle = '#e07c2a';
ctx.fill();
}
}
}
/* Draw colored numbered control-point dots on top */
const colors = ['#e63946','#2a7d4f','#1d6fba','#9b2dca','#c97b22','#0a7d6e'];
pts.forEach((p, i) => {
ctx.beginPath();
ctx.arc(p.x, p.y, RADIUS, 0, 2*Math.PI);
ctx.fillStyle = colors[i % colors.length];
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// White index number inside each dot
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(i + 1, p.x, p.y);
});
statusEl.textContent = i18nFmt('%n% waypoints — curve passes through all of them.', { n: pts.length });
}
// Return the index of the point under (mx, my), or -1
function hitTest(mx, my) {
for (let i = pts.length - 1; i >= 0; i--) {
const dx = pts[i].x - mx, dy = pts[i].y - my;
if (dx*dx + dy*dy <= (RADIUS + 4)*(RADIUS + 4)) 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) };
}
// Left-click drag an existing point or add a new one
canvas.addEventListener('mousedown', e => {
if (e.button === 2) return;
const pos = getPos(e);
const hit = hitTest(pos.x, pos.y);
if (hit >= 0) { dragging = hit; }
else { pts.push({ x: pos.x, y: pos.y }); draw(); }
});
// Right-click removes the point under the cursor (keep at least 2)
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
const pos = getPos(e);
const hit = hitTest(pos.x, pos.y);
if (hit >= 0 && pts.length > 2) { pts.splice(hit, 1); draw(); }
});
canvas.addEventListener('mousemove', e => {
if (dragging === null) return;
const pos = getPos(e);
pts[dragging] = { x: pos.x, y: pos.y };
draw();
});
canvas.addEventListener('mouseup', () => { dragging = null; });
canvas.addEventListener('mouseleave', () => { dragging = null; });
// Mirror mouse interactions for touch screens
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const pos = getPos(e);
const hit = hitTest(pos.x, pos.y);
if (hit >= 0) dragging = hit;
else { pts.push({ x: pos.x, y: pos.y }); draw(); }
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (dragging === null) return;
const pos = getPos(e);
pts[dragging] = { x: pos.x, y: pos.y };
draw();
}, { passive: false });
canvas.addEventListener('touchend', () => { dragging = null; });
btnTangents.addEventListener('click', () => {
showTangents = !showTangents;
btnTangents.classList.toggle('active', showTangents);
btnTangents.setAttribute('aria-pressed', showTangents);
draw();
});
btnReset.addEventListener('click', () => {
pts = [
{ x: 80, y: 220 },
{ x: 170, y: 100 },
{ x: 280, y: 250 },
{ x: 380, y: 90 },
{ x: 480, y: 200 }
];
showTangents = false;
btnTangents.classList.remove('active');
btnTangents.setAttribute('aria-pressed', false);
draw();
});
draw();
Notice how the curve never has a sharp corner: each segment flows into the next with a matching slope. That smooth handoff is C1 continuity in action. Toggle the tangent arrows to see the velocity vectors that make it work.
Comments
Loading comments...