Implementing a Custom Interactive Curve Chart with Canvas and Bezier Curves
This article explains how to build a feature‑rich, responsive curve chart from scratch using HTML5 canvas, covering layer separation, adaptive sizing, custom gradient fills, Bezier curve calculations, point‑on‑path detection, label handling, animation masking, and a complete configuration object with code examples.
Preface – The author needed a flexible line/curve chart with animation, responsive resizing, and richer configuration than ECharts provides, especially for custom segment colors and gradients. The goal is to implement a reusable chart component that can be published on GitHub and npm.
Origin – The initial attempt copied an ECharts visualMap example, but the color customization and gradient handling were insufficient, prompting a full custom implementation.
Analysis of the approach – The drawing process is divided into three canvas layers: a helper layer (axes, legends, grid), a chart layer (lines/curves), and a label layer (data tooltips). Layer separation simplifies animation and clearing.
Responsive adaptation – The canvas width and height must be set directly (not via CSS) to handle window resizing. The X‑axis data are treated as partitions; the actual pixel width per partition is calculated based on the container width.
visualMap: {
type: 'piecewise',
show: false,
dimension: 0,
seriesIndex: 0,
pieces: [
{gt: 1, lt: 3, color: 'rgba(0, 0, 180, 0.4)'},
{gt: 5, lt: 7, color: 'rgba(0, 0, 180, 0.4)'}
]
}The series configuration shows how to define a smooth line without symbols and with a custom markLine for vertical markers.
series: [{
type: 'line',
smooth: 0.6,
symbol: 'none',
lineStyle: {color: '#5470C6', width: 5},
markLine: {symbol: ['none', 'none'], label: {show: false}, data: [{xAxis: 1}, {xAxis: 3}, {xAxis: 5}, {xAxis: 7}]},
areaStyle: {},
data: [
['2019-10-10', 200], ['2019-10-11', 560], ['2019-10-12', 750],
['2019-10-13', 580], ['2019-10-14', 250], ['2019-10-15', 300],
['2019-10-16', 450], ['2019-10-17', 300], ['2019-10-18', 100]
]
}]Scaling calculations – The Y‑axis ratio is computed from the data range, and the X‑axis ratio is derived from the canvas width and the number of data points.
// calculate Y axis ratio
maxY = Math.max.apply(null, concatData);
minY = Math.min.apply(null, concatData);
rangeY = maxY - minY;
ratioY = (height - 2 * margin) / rangeY;
// calculate X axis ratio and step
count = concatData.length;
rangeX = width - 2 * margin;
ratioX = rangeX / (count - dataLen);
stepX = ratioX;Drawing the axes
function drawAxis() {
ctx.beginPath();
ctx.moveTo(margin, margin);
ctx.lineTo(margin, height - margin);
ctx.lineTo(width - margin + 2, height - margin);
ctx.setLineDash([3, 3]);
ctx.strokeStyle = '#aaa';
ctx.stroke();
ctx.setLineDash([1]);
const yLen = newOpt.axisY.data.length;
const xLen = newOpt.axisX.data.length;
// draw Y ticks and labels
for (let i = 0; i < yLen; i++) {
let y = (rangeY * i) / (yLen - 1) + minY;
let yPos = height - margin - (y - minY) * ratioY;
if (i) {
ctx.beginPath();
ctx.moveTo(margin, yPos);
ctx.lineTo(width - margin, yPos);
ctx.strokeStyle = '#ddd';
ctx.stroke();
}
ctx.fillText(newYs[i] + '', margin - 15 - options.axisY.right, yPos + 5);
}
// draw X ticks and labels
for (let i = 0; i < xLen; i++) {
let xPos = margin + i * stepX;
if (i) {
ctx.beginPath();
ctx.moveTo(xPos, height - margin);
ctx.lineTo(xPos, margin);
ctx.strokeStyle = '#ddd';
ctx.stroke();
}
ctx.fillText(newXs[i], xPos - 1, height - margin + 10 + options.axisX.top);
}
}Drawing a single curve – The function handles both straight lines and Bezier curves, records segment data for later hit‑testing, and fills the area with a gradient that changes on hover.
function drawLine(data) {
const {points, id, rgba, lineColor, hoverRgba} = data;
// ... (calculations for start/end area, scaling, etc.)
function darwColorOrLine(lineMode) {
ctx.beginPath();
ctx.moveTo(id ? margin + endAreaX - xkVal : margin + endAreaX,
height - margin - (points[0] - minY) * ratioY);
ctx.lineWidth = 2;
ctx.setLineDash([0, 0]);
for (let i = 0; i < points.length; i++) {
// compute x, y, control points, draw line or bezier
}
ctx.strokeStyle = lineColor;
ctx.stroke();
// close path for area fill
ctx.lineTo(endAreaX, height - margin);
ctx.lineTo(margin + startAreaX, height - margin);
ctx.lineTo(id ? startAreaX : margin + startAreaX, height - margin);
ctx.strokeStyle = 'transparent';
lineMode && ctx.stroke();
}
darwColorOrLine(false);
// gradient fill
const gradient = ctx.createLinearGradient(200, 110, 200, 290);
if (isHover && areaId === id) {
gradient.addColorStop(0, `rgba(${hoverRgba[1][0]}, ${hoverRgba[1][1]}, ${hoverRgba[1][2]}, 1)`);
gradient.addColorStop(1, `rgba(${hoverRgba[0][0]}, ${hoverRgba[0][1]}, ${hoverRgba[0][2]}, 1)`);
} else {
gradient.addColorStop(0, `rgba(${rgba[1][0]}, ${rgba[1][1]}, ${rgba[1][2]}, 1)`);
gradient.addColorStop(1, `rgba(${rgba[0][0]}, ${rgba[0][1]}, ${rgba[0][2]}, 0)`);
}
ctx.fillStyle = gradient;
ctx.fill();
}Bezier curve mathematics – The article provides a function that approximates a cubic Bezier curve using quadratic segments and samples points along the curve.
function getBezierCurvePoints(startX, startY, cp1X, cp1Y, cp2X, cp2Y, endX, endY, steps) {
let points = [];
// approximate cubic with quadratic control points q1, q2
let q1x = startX + ((cp1X - startX) * 2) / 3;
let q1y = startY + ((cp1Y - startY) * 2) / 3;
let q2x = endX + ((cp2X - endX) * 2) / 3;
let q2y = endY + ((cp2Y - endY) * 2) / 3;
for (let i = 0; i <= steps; i++) {
let t = i / steps;
let x = (1 - t) ** 3 * startX + 3 * t * (1 - t) ** 2 * q1x + 3 * t ** 2 * (1 - t) * q2x + t ** 3 * endX;
let y = (1 - t) ** 3 * startY + 3 * t * (1 - t) ** 2 * q1y + 3 * t ** 2 * (1 - t) * q2y + t ** 3 * endY;
points.push({x: +x.toFixed(2), y: +y.toFixed(2)});
}
return points;
}Label and hover handling – By converting mouse coordinates to canvas coordinates, the code determines the current segment, retrieves the corresponding data point, and draws a vertical line, a small circle, and a floating label with the values.
function drawTouchPoint(clientX, clientY) {
// translate to canvas coordinates
// find current areaId by comparing cx with areaList
// find current x index (curInfo.x) and y index (curInfo.y)
// locate the nearest point on the path
// draw vertical guide line and a small arc at the intersection
// create or update a DOM label positioned near the point
}Mask animation – The mask layer is cleared progressively from right to left using clearRect , creating a reveal animation. The animation stops when the mask covers the whole canvas.
function drawAnimate() {
markCtx.clearRect(0, 0, width, height);
markCtx.fillStyle = 'rgba(255, 255, 255, 1)';
markCtx.fillRect(0, 0, width, height);
markCtx.clearRect(width - maskWidth, height - maskHeight, maskWidth, maskHeight);
maskWidth += 20;
maskHeight += 20;
if (maskWidth < width) {
animateId = requestAnimationFrame(drawAnimate);
} else {
cancelAnimationFrame(animateId);
watchEvent();
}
}Configuration object – The final options object defines layout, raw data arrays, axis definitions (including formatters), and series definitions with color gradients and line colors.
export const options = {
layout: {w: 0, h: 0, root: '#container', m: 30},
data: [
[40,60,40,80,10,50,80,0,50,30,20],
[20,30,60,40,30,10,30,20,0,30,40,20],
[20,30,20,40,20,10,10,30,0,30,50,20]
],
axisX: {
data: [...0..32],
format(param) { return param + 'w'; },
top: 4
},
axisY: {
data: [0,20,40,60,80],
format(param) { return param + '人'; },
right: 10
},
series: [
{rgba: [[55,162,255],[116,21,219]], hoverRgba: [[55,162,255],[116,21,219]], lineColor: 'blue'},
{rgba: [[255,0,135],[135,0,157]], hoverRgba: [[255,0,135],[135,0,157]], lineColor: 'purple'},
{rgba: [[255,190,0],[224,62,76]], hoverRgba: [[255,190,0],[224,62,76]], lineColor: 'orange'}
]
};Conclusion – Managing canvas layers, calculating scaling ratios, and handling Bezier curves are the core challenges. For many use‑cases, SVG or DOM‑based rendering may be simpler, but the presented approach offers fine‑grained control over animation, gradients, and interactive labeling.
Sohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.