This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Trong HTML5 Canvas, mọi hình vẽ sau khi vẽ ra đều bị biến thành các pixel phẳng đè lên bộ đệm bitmap (hướng tiếp cận rasterization). Trình duyệt không hề giữ mô hình cây DOM của từng hình tròn hay tam giác như SVG. Do đó, để nhấp chuột, kéo thả hoặc thay đổi trạng thái khi hover, bạn phải tự tay tính toán tọa độ con trỏ và viết các thuật toán toán học kiểm tra va chạm điểm (Hit Detection). Bài học này đi từ nền tảng — lấy tọa độ chuột chính xác — cho tới các kỹ thuật nâng cao như va chạm pixel-perfect và máy trạng thái kéo thả.
1. Lấy Tọa Độ Chuột Chính Xác Trên Canvas
Đây là bước nền tảng mà rất nhiều người mới bỏ qua, dẫn đến việc click bị lệch. Sự kiện chuột của
trình duyệt (như MouseEvent) trả về tọa độ clientX / clientY —
đây là tọa độ so với khung nhìn (viewport) của trình duyệt, KHÔNG phải so với góc
trên bên trái của canvas. Để chuyển về hệ tọa độ nội bộ của canvas, ta cần biết vị trí của canvas trên
màn hình thông qua getBoundingClientRect().
Nhiều người dùng event.offsetX / offsetY vì nó cho tọa độ tương đối ngay.
Tuy nhiên offsetX không đủ tin cậy trong hai trường hợp quan trọng: (1)
khi canvas bị CSS co giãn (kích thước hiển thị khác kích thước thuộc tính
width/height), và (2) khi xử lý sự kiện cảm ứng (touch) — đối tượng
Touch không hề có thuộc tính offsetX. Vì vậy cách chuẩn nhất, hoạt động cho
cả chuột lẫn cảm ứng, là tự tính toán bằng getBoundingClientRect() kết hợp với hệ số
scale.
Vấn đề scale xảy ra như sau: giả sử thuộc tính canvas là width=600 nhưng CSS hiển thị nó
rộng 300px. Khi đó mỗi pixel CSS tương ứng 2 pixel canvas. Ta phải nhân tọa độ với tỉ lệ
canvas.width / rect.width để ánh xạ đúng:
// Chuyển tọa độ con trỏ (clientX/clientY) về hệ tọa độ nội bộ canvas
function getCanvasCoords(canvas, clientX, clientY) {
const rect = canvas.getBoundingClientRect();
// Hệ số scale: tỉ lệ giữa độ phân giải canvas và kích thước hiển thị CSS
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
canvas.addEventListener('mousemove', (e) => {
const { x, y } = getCanvasCoords(canvas, e.clientX, e.clientY);
console.log('Tọa độ canvas:', x, y);
});
Hàm trên hoạt động cho cả màn hình HiDPI (Retina) nếu bạn đã scale context bằng
devicePixelRatio: vì canvas.width phản ánh độ phân giải thật còn
rect.width phản ánh kích thước CSS, tỉ lệ luôn được tính đúng. Dưới đây là phiên bản
thống nhất xử lý được cả sự kiện chuột lẫn cảm ứng:
function pointerCoords(canvas, evt) {
const rect = canvas.getBoundingClientRect();
// Touch event lưu các điểm chạm trong evt.touches; mouse dùng trực tiếp evt
const source = evt.touches ? evt.touches[0] : evt;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (source.clientX - rect.left) * scaleX,
y: (source.clientY - rect.top) * scaleY
};
}
2. Mouse Events Đầy Đủ & Throttling
Canvas chỉ là một phần tử DOM bình thường, nên nó phát ra toàn bộ các sự kiện chuột tiêu chuẩn. Hiểu rõ vai trò từng sự kiện giúp bạn thiết kế tương tác chính xác:
mousedown— nhấn nút chuột xuống; điểm khởi đầu của mọi thao tác kéo, vẽ, chọn.mousemove— di chuyển con trỏ; bắn liên tục, là nơi cần tối ưu hiệu năng nhất.mouseup— thả nút chuột; kết thúc thao tác kéo.click— một chu kỳ down + up trên cùng vị trí; dùng cho chọn đối tượng.dblclick— nhấp đúp; thường mở hoặc chỉnh sửa.wheel— lăn chuột; dùng để zoom hoặc cuộn nội dung canvas.-
contextmenu— chuột phải; thường cần gọipreventDefault()để chặn menu mặc định của trình duyệt.
Sự kiện mousemove có thể bắn hàng trăm lần mỗi giây. Nếu trong handler bạn làm việc nặng
(tính toán va chạm phức tạp, vẽ lại toàn bộ), trang sẽ giật. Giải pháp là throttling:
chỉ xử lý logic nặng theo nhịp khung hình qua requestAnimationFrame, còn handler sự kiện
chỉ lưu lại tọa độ mới nhất.
canvas.addEventListener('mousedown', (e) => startAction(e));
canvas.addEventListener('mouseup', (e) => endAction(e));
canvas.addEventListener('dblclick', (e) => editObject(e));
canvas.addEventListener('wheel', (e) => { e.preventDefault(); zoom(e.deltaY); }, { passive: false });
canvas.addEventListener('contextmenu',(e) => { e.preventDefault(); showCustomMenu(e); });
let pending = null; // tọa độ mới nhất chờ xử lý
let scheduled = false;
canvas.addEventListener('mousemove', (e) => {
pending = getCanvasCoords(canvas, e.clientX, e.clientY);
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => { // chỉ xử lý 1 lần/khung hình
handleMove(pending);
scheduled = false;
});
}
});
3. Touch & Pointer Events (Thống Nhất Chuột + Cảm Ứng)
Trên thiết bị di động, sự kiện chuột không tồn tại — thay vào đó là sự kiện cảm ứng. Viết hai bộ
handler riêng (mouse và touch) rất rườm rà và dễ lỗi. Pointer Events là API hiện đại
hợp nhất chuột, cảm ứng và bút stylus vào một mô hình duy nhất: pointerdown,
pointermove, pointerup. Mỗi sự kiện có thuộc tính
pointerType ('mouse', 'touch', 'pen') và
pointerId để phân biệt từng ngón tay khi đa chạm.
Khi xử lý cảm ứng nhớ gọi e.preventDefault() (với touch-action: none trong
CSS) để chặn trình duyệt cuộn trang hay zoom khi người dùng đang vẽ. Để theo dõi nhiều ngón tay
(multi-touch), ta lưu các pointer đang hoạt động vào một Map theo pointerId:
const activePointers = new Map(); // pointerId -> {x, y}
canvas.style.touchAction = 'none'; // chặn cuộn/zoom mặc định
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId); // giữ pointer kể cả khi ra ngoài canvas
activePointers.set(e.pointerId, pointerCoords(canvas, e));
});
canvas.addEventListener('pointermove', (e) => {
if (activePointers.has(e.pointerId))
activePointers.set(e.pointerId, pointerCoords(canvas, e));
});
canvas.addEventListener('pointerup', (e) => activePointers.delete(e.pointerId));
canvas.addEventListener('pointercancel', (e) => activePointers.delete(e.pointerId));
Khi có đúng hai pointer, ta tính khoảng cách giữa chúng để làm pinch-zoom: tỉ lệ khoảng cách hiện tại so với khoảng cách ban đầu chính là hệ số phóng to.
function pinchDistance() {
const pts = [...activePointers.values()];
if (pts.length < 2) return null;
const dx = pts[0].x - pts[1].x, dy = pts[0].y - pts[1].y;
return Math.hypot(dx, dy);
}
let startDist = null, baseScale = 1;
canvas.addEventListener('pointermove', () => {
const d = pinchDistance();
if (d === null) { startDist = null; return; }
if (startDist === null) startDist = d; // chốt khoảng cách ban đầu
const scale = baseScale * (d / startDist); // hệ số zoom
applyZoom(scale);
});
4. Hit Detection Cơ Bản
Trước khi tới các đa giác phức tạp, hãy nắm vững ba phép kiểm tra va chạm điểm cơ bản — chúng giải quyết 90% nhu cầu thực tế và cực nhanh vì chỉ là vài phép so sánh số học. Chìa khóa là so sánh tọa độ con trỏ (đã chuyển về hệ canvas ở Mục 1) với hình học của đối tượng.
Point-in-rect: điểm nằm trong hình chữ nhật khi tọa độ của nó nằm giữa các cạnh.
Point-in-circle: dùng khoảng cách từ điểm tới tâm — nếu nhỏ hơn bán kính thì trúng;
mẹo tối ưu là so sánh bình phương khoảng cách để tránh phép Math.sqrt() tốn kém.
Bounding box (AABB): với hình phức tạp, ta bọc nó trong một chữ nhật bao và kiểm tra
nhanh trước (broad phase) rồi mới kiểm tra chi tiết.
// Điểm trong hình chữ nhật
function pointInRect(px, py, r) {
return px >= r.x && px <= r.x + r.w &&
py >= r.y && py <= r.y + r.h;
}
// Điểm trong hình tròn (so sánh bình phương, không cần sqrt)
function pointInCircle(px, py, c) {
const dx = px - c.x, dy = py - c.y;
return dx * dx + dy * dy <= c.r * c.r;
}
// Khoảng cách Euclid giữa hai điểm
function distance(ax, ay, bx, by) {
return Math.hypot(bx - ax, by - ay);
}
// AABB overlap: hai hộp chữ nhật có giao nhau không (dùng cho broad phase)
function aabbOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y;
}
5. Thuật Toán Va Chạm Đa Giác Bất Kỳ (Ray Casting Algorithm)
Đối với hình chữ nhật hoặc hình tròn, thuật toán va chạm rất đơn giản. Tuy nhiên, nếu bạn muốn kiểm tra xem một điểm chuột \(P(x, y)\) có nằm bên trong một đa giác lồi/lõm bất kỳ hay không, thuật toán kinh điển nhất là Ray Casting (Even-Odd Rule):
[Nguyên lý toán học]: Bắt đầu từ điểm kiểm tra \(P\), ta vẽ một tia nằm ngang kéo dài vô tận sang bên phải. Ta đếm số lần tia này giao cắt với các cạnh của đa giác.
• Nếu số giao điểm là lẻ (Odd): Điểm đó nằm trong đa giác.
• Nếu số giao điểm là chẵn (Even): Điểm đó nằm ngoài đa giác.
Dưới đây là mã nguồn thuật toán Even-Odd Rule kiểm tra đa giác:
function isPointInPolygon(px, py, vertices) {
let inside = false;
const n = vertices.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = vertices[i].x, yi = vertices[i].y;
const xj = vertices[j].x, yj = vertices[j].y;
// Kiểm tra xem tia ngang xuất phát từ (px, py) có cắt cạnh nối i và j không
const intersect = ((yi > py) !== (yj > py))
&& (px < (xj - xi) * (py - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
6. Va Chạm Chính Xác Từng Pixel (Pixel-Perfect Hit Detection)
Khi đối tượng đồ họa có hình dạng rất phức tạp (như con cá, cành cây, các lỗ thủng), việc tính toán va chạm bằng toán học đa giác trở nên vô cùng khó khăn. Giải pháp thay thế là kiểm tra kênh alpha của pixel tại tọa độ chuột.
function checkPixelHit(ctx, x, y) {
// Lấy dữ liệu màu 1x1 pixel tại toạ độ x,y
const imgData = ctx.getImageData(x, y, 1, 1);
const alpha = imgData.data[3]; // Kênh alpha: 0 (transparent) -> 255 (opaque)
return alpha > 0; // Trả về true nếu pixel có màu
}
[CẢNH BÁO HIỆU NĂNG]: Hàm
getImageData()là một trong những hàm chậm nhất trong Canvas API. Nó buộc CPU phải dừng tất cả các lệnh dựng đồ họa xếp hàng (render pipeline) lại để thực hiện đồng bộ dữ liệu từ GPU frame buffer về bộ nhớ RAM của CPU. Quá nhiều lệnh `getImageData` sẽ làm FPS giảm xuống thê thảm.
Cách Tối Ưu Hóa:
- Giai đoạn 1 (Broad Phase): Kiểm tra AABB (hộp bao ngoài chữ nhật) của đối tượng bằng công thức toán học nhanh. Chỉ khi chuột nằm trong hộp bao, ta mới chuyển sang Giai đoạn 2.
-
Giai đoạn 2 (Narrow Phase): Vẽ đối tượng đó đơn độc lên một
Offscreen Canvas nhỏ tương ứng kích thước của nó, sau đó gọi
getImageDatatrên offscreen canvas đó để đọc alpha. Cách này tránh đồng bộ bộ đệm màn hình lớn của trình duyệt.
7. Vòng Đời Kéo Thả (Drag & Drop State Machine)
Để triển khai tính năng Kéo Thả nhiều đối tượng mượt mà, ta phải quản lý trạng thái chuột chặt chẽ theo mô hình máy trạng thái dưới đây:
Để kéo thả không bị giật, ta luôn luôn tính toán dragOffset tại thời điểm nhấn chuột
xuống (mousedown):
\(\text{dragOffset.x} = \text{mouseX} - \text{shape.x}\)
\(\text{dragOffset.y} = \text{mouseY} - \text{shape.y}\)
Trong quá trình di chuyển (mousemove hoặc pointermove), ta liên tục gán tọa
độ mới của đối tượng bằng tọa độ chuột hiện tại trừ đi dragOffset để giữ nguyên vị trí
chuột tương đối trên vật thể. Một mẹo quan trọng khi có nhiều đối tượng chồng lên nhau: khi
pointerdown, ta nên tìm đối tượng gần con trỏ nhất (hoặc nằm trên cùng
theo thứ tự vẽ) để chọn — tránh việc grab nhầm vật ở phía dưới. Đoạn mã dưới triển khai logic chọn vật
gần nhất:
let dragging = null, dragOffset = { x: 0, y: 0 };
canvas.addEventListener('pointerdown', (e) => {
const m = pointerCoords(canvas, e);
// Duyệt ngược để ưu tiên vật vẽ sau cùng (nằm trên cùng)
for (let i = circles.length - 1; i >= 0; i--) {
if (pointInCircle(m.x, m.y, circles[i])) {
dragging = circles[i];
dragOffset.x = m.x - dragging.x; // chốt offset ngay lúc grab
dragOffset.y = m.y - dragging.y;
break;
}
}
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const m = pointerCoords(canvas, e);
dragging.x = m.x - dragOffset.x; // giữ vị trí tương đối
dragging.y = m.y - dragOffset.y;
});
canvas.addEventListener('pointerup', () => { dragging = null; });
8. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 0: Tọa độ chuột
Vì sao nên dùng getBoundingClientRect() + scale thay vì event.offsetX để
lấy tọa độ trên canvas?
9. Câu hỏi trắc nghiệm nâng cao
Trắc nghiệm 1: Even-Odd Rule
Tia nằm ngang từ điểm kiểm tra cắt các cạnh đa giác 3 lần. Điểm đó nằm trong hay ngoài đa giác?
Trắc nghiệm 2: getImageData Performance
Tại sao không nên lạm dụng getImageData để kiểm tra va chạm liên tục?
Trắc nghiệm 3: Drag & Drop
Nếu không trừ đi `dragOffset` khi di chuyển chuột kéo thả thì hiện tượng gì xảy ra?
Tải file code thực hành minh họa bài học
File script hoàn chỉnh vẽ các đối tượng hình học tương tác, drag-and-drop và đổi cursor chuột:
Tải về canvas_interaction.js
Comments
Bình luận