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:

📊 Demo tương tác: Animated Bar / Line Chart
bar_chart.js
// 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.js
// 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:

🥧 Demo tương tác: Pie / Donut + Tooltip

💡 Rê chuột lên từng phần để xem chi tiết.

pie_chart.js
// 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.js
// 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_chart.js
// 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

Related Articles

Bài viết liên quan trong series

Lesson 17: Bài 14: Creative Coding & Performance Optimization Bài 17: Creative Coding & Performance Optimization Lesson 15: Web Audio API & Game Juice for Canvas Bài 15: Web Audio API & Game Juice cho Canvas Back to Canvas Series Overview Quay lại Lộ trình Canvas Series