This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Bài 13 hướng dẫn vẽ các loại biểu đồ phổ biến từ đầu trên Canvas: bar chart, line chart, pie/donut, radar chart, kèm interactive tooltips và animation.
1. Bar Chart
Bar chart là biểu đồ đơn giản nhất: mỗi giá trị dữ liệu tương ứng 1 thanh (rectangle) với chiều cao tỷ lệ thuận với giá trị. Demo bên dưới vẽ biểu đồ với hiệu ứng các thanh "mọc lên" (animated). Nhấn Đổi dữ liệu để xem chúng tween sang giá trị mới, và đổi giữa Bar/Line:
// Bar Chart from scratch
function drawBarChart(ctx, data, options = {}) {
const {
x = 60, y = 20,
width = 500, height = 300,
colors = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#a855f7', '#ec4899'],
barPadding = 0.2, // 20% of bar width
animate = true
} = options;
const maxValue = Math.max(...data.map(d => d.value));
const barCount = data.length;
const barWidth = width / barCount;
const innerBarWidth = barWidth * (1 - barPadding);
// Animation progress (0 → 1)
let progress = animate ? 0 : 1;
function render() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// Y-axis grid lines
ctx.strokeStyle = '#334155';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 5; i++) {
const gridY = y + height - (height / 5) * i;
ctx.beginPath();
ctx.moveTo(x, gridY);
ctx.lineTo(x + width, gridY);
ctx.stroke();
// Y-axis labels
ctx.fillStyle = '#94a3b8';
ctx.font = '12px monospace';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxValue / 5 * i), x - 8, gridY + 4);
}
// Bars
data.forEach((item, i) => {
const barHeight = (item.value / maxValue) * height * progress;
const bx = x + i * barWidth + (barWidth - innerBarWidth) / 2;
const by = y + height - barHeight;
ctx.fillStyle = colors[i % colors.length];
ctx.fillRect(bx, by, innerBarWidth, barHeight);
// Value on top
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 13px monospace';
ctx.textAlign = 'center';
ctx.fillText(Math.round(item.value * progress), bx + innerBarWidth / 2, by - 6);
// X-axis label (rotated)
ctx.save();
ctx.translate(bx + innerBarWidth / 2, y + height + 16);
ctx.rotate(-Math.PI / 6);
ctx.fillStyle = '#94a3b8';
ctx.font = '11px monospace';
ctx.textAlign = 'right';
ctx.fillText(item.label, 0, 0);
ctx.restore();
});
if (progress < 1) {
progress = Math.min(progress + 0.02, 1);
requestAnimationFrame(render);
}
}
render();
}
// Sử dụng
const salesData = [
{ label: 'Jan', value: 120 },
{ label: 'Feb', value: 85 },
{ label: 'Mar', value: 200 },
{ label: 'Apr', value: 160 },
{ label: 'May', value: 240 },
{ label: 'Jun', value: 190 }
];
drawBarChart(ctx, salesData);
2. Line Chart
Line chart kết nối các data points bằng đường thẳng hoặc đường cong Bezier. Thêm gradient fill bên dưới để tạo area chart.
// Line Chart with smooth curves and area fill
function drawLineChart(ctx, datasets, options = {}) {
const {
x = 60, y = 20,
width = 500, height = 300,
showGrid = true, showDots = true, fillArea = true
} = options;
const allValues = datasets.flatMap(ds => ds.data);
const maxValue = Math.max(...allValues);
const pointCount = datasets[0].data.length;
// Grid lines
if (showGrid) {
ctx.strokeStyle = '#334155';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 5; i++) {
const gridY = y + height - (height / 5) * i;
ctx.beginPath();
ctx.moveTo(x, gridY);
ctx.lineTo(x + width, gridY);
ctx.stroke();
}
}
// Draw each dataset
datasets.forEach(dataset => {
const points = dataset.data.map((val, i) => ({
px: x + (i / (pointCount - 1)) * width,
py: y + height - (val / maxValue) * height
}));
// Smooth line with quadratic bezier
ctx.beginPath();
ctx.moveTo(points[0].px, points[0].py);
for (let i = 0; i < points.length - 1; i++) {
const curr = points[i];
const next = points[i + 1];
const midX = (curr.px + next.px) / 2;
const midY = (curr.py + next.py) / 2;
ctx.quadraticCurveTo(curr.px, curr.py, midX, midY);
}
const last = points[points.length - 1];
ctx.lineTo(last.px, last.py);
// Stroke line
ctx.strokeStyle = dataset.color;
ctx.lineWidth = 2.5;
ctx.stroke();
// Fill area under line with gradient
if (fillArea) {
ctx.lineTo(x + width, y + height);
ctx.lineTo(x, y + height);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, y, 0, y + height);
gradient.addColorStop(0, dataset.color + '40'); // alpha 25%
gradient.addColorStop(1, dataset.color + '05');
ctx.fillStyle = gradient;
ctx.fill();
}
// Dots on data points
if (showDots) {
for (const p of points) {
ctx.beginPath();
ctx.arc(p.px, p.py, 4, 0, Math.PI * 2);
ctx.fillStyle = dataset.color;
ctx.fill();
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 2;
ctx.stroke();
}
}
});
}
// Multiple datasets
drawLineChart(ctx, [
{ data: [30, 50, 80, 60, 90, 120, 100], color: '#3b82f6', label: 'Revenue' },
{ data: [20, 35, 45, 55, 40, 70, 85], color: '#22c55e', label: 'Profit' }
]);
3. Pie & Donut Chart
Pie chart dùng arc() để vẽ từng slice. Tính góc bắt đầu và kết thúc dựa trên phần trăm.
Rê chuột lên các phần trong demo bên dưới — slice được trỏ sẽ nổi bật và hiện tooltip
với giá trị + phần trăm. Kéo thanh trượt để biến Pie thành Donut:
💡 Rê chuột lên từng phần để xem chi tiết.
// Pie & Donut Chart
function drawPieChart(ctx, data, options = {}) {
const {
cx = 300, cy = 200, radius = 140,
donut = false, donutRatio = 0.55,
colors = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#a855f7', '#ec4899'],
animate = true
} = options;
const total = data.reduce((sum, d) => sum + d.value, 0);
let animAngle = animate ? 0 : Math.PI * 2;
function render() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
let startAngle = -Math.PI / 2; // bắt đầu từ 12 giờ
data.forEach((item, i) => {
const sliceAngle = (item.value / total) * Math.PI * 2;
const endAngle = startAngle + sliceAngle;
const drawEnd = Math.min(endAngle, -Math.PI / 2 + animAngle);
if (drawEnd > startAngle) {
// Vẽ slice
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, drawEnd);
ctx.closePath();
ctx.fillStyle = colors[i % colors.length];
ctx.fill();
// Label bên ngoài
if (animAngle >= Math.PI * 2) {
const midAngle = startAngle + sliceAngle / 2;
const labelR = radius + 25;
const lx = cx + Math.cos(midAngle) * labelR;
const ly = cy + Math.sin(midAngle) * labelR;
const pct = ((item.value / total) * 100).toFixed(1);
ctx.fillStyle = '#e2e8f0';
ctx.font = '12px monospace';
ctx.textAlign = midAngle > Math.PI / 2 && midAngle < Math.PI * 1.5 ? 'right' : 'left';
ctx.fillText(`${item.label} (${pct}%)`, lx, ly);
}
}
startAngle = endAngle;
});
// Donut: vẽ circle ở giữa
if (donut) {
ctx.beginPath();
ctx.arc(cx, cy, radius * donutRatio, 0, Math.PI * 2);
ctx.fillStyle = '#0f172a'; // hoặc background color
ctx.fill();
// Total ở giữa
if (animAngle >= Math.PI * 2) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 24px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total.toString(), cx, cy);
}
}
if (animAngle < Math.PI * 2) {
animAngle = Math.min(animAngle + 0.06, Math.PI * 2);
requestAnimationFrame(render);
}
}
render();
}
// Sử dụng
drawPieChart(ctx, [
{ label: 'Chrome', value: 65 },
{ label: 'Safari', value: 18 },
{ label: 'Firefox', value: 7 },
{ label: 'Edge', value: 6 },
{ label: 'Other', value: 4 }
], { donut: true });
4. Radar Chart
Radar chart vẽ dữ liệu đa chiều trên các trục tỏa ra từ tâm, tạo hình đa giác. Rất hữu ích khi so sánh nhiều thuộc tính.
// Radar Chart
function drawRadarChart(ctx, labels, datasets, options = {}) {
const {
cx = 300, cy = 220, radius = 150,
levels = 5, maxValue = 100
} = options;
const sides = labels.length;
const angleStep = (Math.PI * 2) / sides;
// Helper: point on axis at distance
function axisPoint(index, dist) {
const angle = -Math.PI / 2 + index * angleStep;
return {
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist
};
}
// Draw grid (concentric polygons)
for (let lvl = 1; lvl <= levels; lvl++) {
const r = (lvl / levels) * radius;
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const p = axisPoint(i, r);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.strokeStyle = '#334155';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// Draw axes
for (let i = 0; i < sides; i++) {
const p = axisPoint(i, radius);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(p.x, p.y);
ctx.strokeStyle = '#475569';
ctx.lineWidth = 0.5;
ctx.stroke();
// Labels
const labelP = axisPoint(i, radius + 20);
ctx.fillStyle = '#94a3b8';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(labels[i], labelP.x, labelP.y);
}
// Plot datasets
datasets.forEach(dataset => {
ctx.beginPath();
dataset.data.forEach((val, i) => {
const dist = (val / maxValue) * radius;
const p = axisPoint(i, dist);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
});
ctx.closePath();
// Fill
ctx.fillStyle = dataset.color + '30'; // alpha
ctx.fill();
// Stroke
ctx.strokeStyle = dataset.color;
ctx.lineWidth = 2;
ctx.stroke();
// Dots
dataset.data.forEach((val, i) => {
const dist = (val / maxValue) * radius;
const p = axisPoint(i, dist);
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = dataset.color;
ctx.fill();
});
});
}
// Sử dụng
drawRadarChart(ctx,
['Speed', 'Power', 'Defense', 'Stamina', 'Technique', 'Agility'],
[
{ data: [90, 60, 70, 80, 95, 85], color: '#3b82f6', label: 'Player A' },
{ data: [70, 90, 85, 60, 70, 75], color: '#ef4444', label: 'Player B' }
]
);
5. Interactive: Tooltips & Hover
Thêm tương tác: detect mouse hover trên bar/slice/point, hiển thị tooltip, highlight phần tử đang hover.
// Interactive Bar Chart with Tooltips
class InteractiveBarChart {
constructor(canvas, data) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.data = data;
this.hoveredIndex = -1;
this.bars = []; // store bar rects for hit testing
canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
canvas.addEventListener('click', (e) => this.onClick(e));
window.addEventListener('resize', () => this.onResize());
this.calculate();
this.draw();
}
calculate() {
const padding = { top: 30, right: 30, bottom: 50, left: 60 };
const w = this.canvas.width - padding.left - padding.right;
const h = this.canvas.height - padding.top - padding.bottom;
const max = Math.max(...this.data.map(d => d.value));
const barW = w / this.data.length * 0.7;
const gap = w / this.data.length * 0.3;
this.bars = this.data.map((item, i) => ({
x: padding.left + i * (barW + gap) + gap / 2,
y: padding.top + h - (item.value / max) * h,
w: barW,
h: (item.value / max) * h,
label: item.label,
value: item.value
}));
}
onMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const prev = this.hoveredIndex;
this.hoveredIndex = this.bars.findIndex(b =>
mx >= b.x && mx <= b.x + b.w && my >= b.y && my <= b.y + b.h
);
if (this.hoveredIndex !== prev) {
this.canvas.style.cursor = this.hoveredIndex >= 0 ? 'pointer' : 'default';
this.draw();
}
this.mouseX = mx;
this.mouseY = my;
if (this.hoveredIndex >= 0) this.draw(); // redraw tooltip position
}
onClick(e) {
if (this.hoveredIndex >= 0) {
const bar = this.bars[this.hoveredIndex];
console.log(`Clicked: ${bar.label} = ${bar.value}`);
}
}
onResize() {
// Responsive resize
this.canvas.width = this.canvas.parentElement.clientWidth;
this.calculate();
this.draw();
}
draw() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
const colors = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#a855f7', '#ec4899'];
this.bars.forEach((bar, i) => {
const isHovered = i === this.hoveredIndex;
ctx.fillStyle = isHovered
? colors[i % colors.length] + 'cc' // brighter
: colors[i % colors.length] + '99';
ctx.fillRect(bar.x, bar.y, bar.w, bar.h);
// Highlight border on hover
if (isHovered) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(bar.x, bar.y, bar.w, bar.h);
}
});
// Tooltip
if (this.hoveredIndex >= 0) {
const bar = this.bars[this.hoveredIndex];
const text = `${bar.label}: ${bar.value}`;
ctx.font = '13px monospace';
const tw = ctx.measureText(text).width;
const tx = this.mouseX + 12;
const ty = this.mouseY - 30;
// Background
ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
ctx.fillRect(tx - 6, ty - 14, tw + 12, 22);
ctx.strokeStyle = '#475569';
ctx.lineWidth = 1;
ctx.strokeRect(tx - 6, ty - 14, tw + 12, 22);
// Text
ctx.fillStyle = '#f1f5f9';
ctx.fillText(text, tx, ty);
}
}
}
// So sánh: khi nào dùng Canvas charts vs Chart.js vs D3.js?
// - Canvas tự viết: kiểm soát 100%, hiệu năng cao, nhưng tốn thời gian
// - Chart.js: nhanh, đẹp, config-based, đủ cho hầu hết use cases
// - D3.js: mạnh nhất, SVG-based, hoàn toàn customizable, learning curve cao
new InteractiveBarChart(canvas, salesData);
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: Bar Chart
Để scale giá trị dữ liệu thành chiều cao bar, công thức nào đúng?
Trắc nghiệm 2: Pie Chart
Để tính góc (angle) của 1 slice trong pie chart từ giá trị, dùng công thức nào?
Trắc nghiệm 3: Canvas vs Library
Khi nào nên tự vẽ chart bằng Canvas thay vì dùng Chart.js?
Tải file code thực hành minh họa bài học
File tổng hợp bar, line, pie, radar chart với animation và tooltips:
Tải về canvas_charts.js
Comments
Bình luận