Tại Sao Chọn D3.js với TypeScript — và Tại Sao Kết Hợp Này Hiệu Quả
Hai năm trước, tôi tiếp nhận một dự án hiển thị dữ liệu CSV thô trong một bảng HTML đơn giản. Khách hàng muốn xem xu hướng theo mùa, so sánh các mẫu theo vùng và tương tác để xem chi tiết — tất cả trong trình duyệt. Các thư viện biểu đồ thông thường trông ổn cho đến khi chúng tôi cần các tương tác tùy biến. Đó là lúc tôi chuyển sang D3.js kết hợp với TypeScript, và mọi dashboard production tôi xây dựng từ đó đến nay đều dùng stack này.
D3 (Data-Driven Documents) cho bạn kiểm soát trực tiếp từng phần tử SVG trên trang. TypeScript bổ sung type safety giúp codebase D3 lớn dễ bảo trì — bạn phát hiện lỗi về kiểu dữ liệu lúc compile thay vì lúc 2 giờ sáng khi khách hàng gửi ảnh chụp màn hình biểu đồ bị vỡ. Tôi đã dùng stack này trong nhiều dự án production. Dự án lớn nhất xử lý hơn 70.000 điểm dữ liệu mỗi biểu đồ mà không bị giật khung hình.
Chúng ta sẽ đề cập đến việc thiết lập dự án, sau đó xây dựng ba thành phần bạn thực sự sẽ tái sử dụng: biểu đồ đường có animation, heat map và dashboard kết hợp. Phần cuối bàn về kiểm tra hiệu suất và phát hiện regression trước khi người dùng nhìn thấy.
Cài Đặt & Thiết Lập Dự Án
Vite là cách nhanh nhất để tạo dự án TypeScript. Nó compile TypeScript tự động và khởi động server phát triển hot-reload — không cần vật lộn với config webpack.
npm create vite@latest d3-dashboard -- --template vanilla-ts
cd d3-dashboard
npm install
Bây giờ cài D3 và các định nghĩa TypeScript của nó:
npm install d3
npm install --save-dev @types/d3
Package @types/d3 bao phủ toàn bộ D3 API — không cần đoán mò scaleLinear() trả về gì hay axisBottom() nhận những tham số nào.
Mở tsconfig.json và xác nhận strict mode đã bật:
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
Strict mode bắt buộc kiểm tra null ở khắp nơi — rất quan trọng khi D3 chọn các phần tử DOM có thể chưa tồn tại lúc code chạy.
Xây Dựng Các Biểu Đồ
Biểu Đồ Đường với Animation Vẽ
Tạo src/lineChart.ts. File này minh họa ba pattern D3 bạn sẽ dùng trong hầu hết mọi biểu đồ: scale, trục và vẽ đường path.
import * as d3 from 'd3';
interface DataPoint {
date: Date;
value: number;
}
export function drawLineChart(
selector: string,
data: DataPoint[]
): void {
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const width = 700 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
d3.select(selector).selectAll('*').remove();
const svg = d3
.select(selector)
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const x = d3.scaleTime()
.domain(d3.extent(data, d => d.date) as [Date, Date])
.range([0, width]);
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value) as number])
.nice()
.range([height, 0]);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x).ticks(6));
svg.append('g').call(d3.axisLeft(y));
const line = d3.line<DataPoint>()
.x(d => x(d.date))
.y(d => y(d.value))
.curve(d3.curveMonotoneX);
const path = svg.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', '#4f8ef7')
.attr('stroke-width', 2.5)
.attr('d', line);
// Animation vẽ đường dùng thủ thuật stroke-dashoffset
const totalLength = (path.node() as SVGPathElement).getTotalLength();
path
.attr('stroke-dasharray', `${totalLength} ${totalLength}`)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(1200)
.ease(d3.easeCubicOut)
.attr('stroke-dashoffset', 0);
// Tooltip
const tooltip = d3.select('body')
.append('div')
.style('position', 'absolute')
.style('background', '#333')
.style('color', '#fff')
.style('padding', '6px 10px')
.style('border-radius', '4px')
.style('pointer-events', 'none')
.style('opacity', 0);
svg.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => x(d.date))
.attr('cy', d => y(d.value))
.attr('r', 4)
.attr('fill', '#4f8ef7')
.on('mouseover', (event, d) => {
tooltip.transition().duration(150).style('opacity', 1);
tooltip
.html(`${d.date.toLocaleDateString()}: <strong>${d.value}</strong>`)
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`);
})
.on('mouseout', () =>
tooltip.transition().duration(200).style('opacity', 0)
);
}
Animation chạy thông qua thủ thuật SVG stroke-dasharray / stroke-dashoffset: đặt độ dài nét đứt bằng tổng độ dài đường path, sau đó chuyển offset về 0 trong 1.200ms. Đường tự vẽ lên. Cast path.node() as SVGPathElement báo cho TypeScript biết đây là SVG path — không phải phần tử HTML thông thường — để getTotalLength() hoạt động mà không báo lỗi type.
Heat Map để Khám Phá Mẫu
Heat map nén mật độ hai chiều thành thứ gì đó có thể đọc ngay lập tức — tải server theo ngày và giờ, hoạt động commit hàng tuần, mẫu doanh số. Bất kỳ dữ liệu nào có hai trục phân loại và một cường độ số đều phù hợp với dạng này. Tạo src/heatMap.ts:
import * as d3 from 'd3';
interface HeatCell {
day: string;
hour: number;
value: number;
}
export function drawHeatMap(selector: string, data: HeatCell[]): void {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const hours = d3.range(0, 24);
const cellSize = 28;
const margin = { top: 40, right: 20, bottom: 20, left: 50 };
d3.select(selector).selectAll('*').remove();
const svg = d3
.select(selector)
.append('svg')
.attr('width', hours.length * cellSize + margin.left + margin.right)
.attr('height', days.length * cellSize + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const colorScale = d3
.scaleSequential()
.domain([0, d3.max(data, d => d.value) as number])
.interpolator(d3.interpolateYlOrRd);
svg.selectAll('.hour-label')
.data(hours)
.join('text')
.attr('x', d => d * cellSize + cellSize / 2)
.attr('y', -8)
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.text(d => d % 3 === 0 ? `${d}h` : '');
svg.selectAll('.day-label')
.data(days)
.join('text')
.attr('x', -8)
.attr('y', (_, i) => i * cellSize + cellSize / 2)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'end')
.attr('font-size', 11)
.text(d => d);
svg.selectAll('rect')
.data(data)
.join('rect')
.attr('x', d => d.hour * cellSize)
.attr('y', d => days.indexOf(d.day) * cellSize)
.attr('width', cellSize - 2)
.attr('height', cellSize - 2)
.attr('rx', 3)
.attr('fill', d => colorScale(d.value))
.append('title')
.text(d => `${d.day} ${d.hour}:00 — ${d.value} sự kiện`);
}
d3.scaleSequential với interpolateYlOrRd tự động ánh xạ miền số của bạn sang gradient vàng-đến-đỏ. Mỗi <title> gắn vào rect cho bạn tooltip trình duyệt gốc — không cần tính toán vị trí, không cần thư viện thêm.
Kết Nối Dashboard
Trong src/main.ts, kết hợp cả hai biểu đồ bằng một nguồn dữ liệu chung:
import * as d3 from 'd3';
import { drawLineChart } from './lineChart';
import { drawHeatMap } from './heatMap';
async function bootstrap() {
// Thay thế bằng các lệnh fetch() thực khi kết nối với API
const lineData = d3
.timeDays(new Date('2025-01-01'), new Date('2025-07-01'))
.map(date => ({
date,
value: Math.round(
50 + Math.random() * 80 + Math.sin(date.getMonth()) * 30
),
}));
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const heatData = days.flatMap(day =>
d3.range(0, 24).map(hour => ({
day,
hour,
value: Math.round(Math.random() * 100),
}))
);
drawLineChart('#line-chart', lineData);
drawHeatMap('#heat-map', heatData);
}
bootstrap();
Phía HTML rất đơn giản:
<div id="line-chart"></div>
<div id="heat-map" style="margin-top: 2rem"></div>
Khi API thực tế của bạn sẵn sàng, thay Math.random() bằng await fetch('/api/metrics').then(r => r.json()). Các hàm biểu đồ vẫn y nguyên — chỉ nguồn dữ liệu thay đổi.
Kiểm Tra & Giám Sát
Kiểm tra tính đúng đắn TypeScript trong CI bằng chế độ no-emit của trình biên dịch — nó chạy kiểm tra type mà không tạo file đầu ra:
npx tsc --noEmit
Không có lỗi nghĩa là các interface dữ liệu của bạn khớp với những gì D3 mong đợi. Chạy lệnh này trên mọi pull request để phát hiện thay đổi API schema trước khi vào production.
Phân tích hiệu suất render trong Chrome DevTools ở tab Performance. Ghi lại khi tải 10.000 điểm dữ liệu. Các thanh đỏ dài trong flame chart thường có nghĩa là có quá nhiều phần tử SVG. Chuyển các phần tử lớp tooltip sang Canvas trong khi giữ SVG cho trục và nhãn — D3 hỗ trợ cả hai renderer trong cùng một component.
Làm cho biểu đồ responsive bằng cách đọc chiều rộng container lúc vẽ thay vì hardcode:
const containerWidth = (
d3.select(selector).node() as HTMLElement
).getBoundingClientRect().width;
const width = containerWidth - margin.left - margin.right;
Gắn một ResizeObserver để chạy lại hàm vẽ mỗi khi viewport thay đổi:
const container = document.querySelector(selector) as HTMLElement;
new ResizeObserver(() => drawLineChart(selector, data)).observe(container);
Desktop và mobile, xử lý ở một nơi. Tôi đưa pattern này vào mọi dashboard mới mà không cần suy nghĩ thêm.
Theo dõi độ ổn định layout bằng thư viện Web Vitals. SVG thay đổi kích thước sau lần paint đầu tiên gây ra Cumulative Layout Shift, làm giảm điểm Core Web Vitals:
npm install web-vitals
import { onCLS, onLCP } from 'web-vitals';
onCLS(metric => console.log('CLS:', metric.value));
onLCP(metric => console.log('LCP:', metric.value));
Điểm CLS trên 0.1 nghĩa là kích thước SVG chưa được khai báo trước lần paint đầu tiên. Thêm thuộc tính width và height rõ ràng vào thẻ <svg> gốc, hoặc bọc div container trong CSS aspect-ratio rule.
Khởi động server phát triển và mở dashboard:
npm run dev
Biểu đồ đường tự vẽ với animation cubic mượt mà 1,2 giây. Heat map tô màu từ vàng nhạt đến đỏ đậm. Di chuột qua bất kỳ điểm nào trên biểu đồ đường — tooltip xuất hiện trong 150ms và mờ dần sạch sẽ khi mouseout.
Ba bước tiếp theo rõ ràng: thêm zoom với d3.zoom(), liên kết các biểu đồ để nhấp vào một ngày trên heat map lọc biểu đồ đường, hoặc thay generator ngẫu nhiên bằng dữ liệu API thực. Các interface có type, hàm vẽ tái sử dụng và ánh xạ tọa độ dựa trên scale bạn vừa xây dựng là những pattern bạn sẽ dùng trong mọi dự án D3 sau này.

