Canvas interaktif artinya gambar di canvas bisa disentuh, diseret dan diubah, padahal canvas cuma menyimpan pixel. Bayangin papan sablon Raka di etalase: pelanggan menunjuk "yang bulat itu" di balik kaca. Kacanya gak tahu apa yang ditunjuk. Raka yang tahu, karena ia punya catatan: stiker bulat di sini, cangkir di sana. Di canvas, catatan itu adalah array objek milikmu, dan mencocokkan posisi jari dengan catatan itu disebut hit testing.
Di lesson ini Sablon Kopi Raka jadi desain yang bisa diatur pelanggan: cangkir, tulisan KOPI dan stiker bulat "R" bisa diseret dan diganti warna tintanya.
🧪 Tebakan cepat: canvas 300 × 300 ditampilkan selebar 240 px di HP. Pelanggan menyentuh tepat di tengahnya, dan
e.clientX - rect.leftbernilai 120. Titik mana di gambar yang disentuh: x 120 atau x 150? Tebak dulu, jawabannya ada di bagian "Dari layar ke koordinat canvas".
Canvas cuma tahu pixel
Elemen HTML biasa (dan SVG) adalah objek di DOM: tombol bisa diberi addEventListener('click') sendiri. Canvas hanya menyimpan pixel. Begitu cangkir digambar, gak ada objek "cangkir" yang bisa dipasangi listener. Jadi kamu menyimpan desainnya sendiri, lalu menggambarnya dari data itu:
const desain = [
{ jenis: 'cangkir', x: 150, y: 165, tinta: '#c47a3d' },
{ jenis: 'teks', x: 150, y: 282, lebar: 108, tinta: '#f5e6c8' }, // lebar: measureText('KOPI').width, dibulatkan
{ jenis: 'stiker', x: 235, y: 70, r: 28, tinta: '#c47a3d' },
];
function gambar() {
ctx.clearRect(0, 0, 300, 300);
for (const benda of desain) gambarBenda(benda); // urutan gambar = urutan tumpukan
}
Listener-nya cuma satu, di canvas, dan tiap kali ada sentuhan kamu sendiri yang mencari benda mana yang kena. Setelah data berubah (posisi atau tinta), panggil gambar() lagi.
Pointer events
pointerdown, pointermove dan pointerup adalah satu set event untuk mouse, jari dan stylus sekaligus, jadi kamu gak perlu menulis versi mouse... dan touch... terpisah.
canvas.addEventListener('pointerdown', mulaiSeret);
canvas.addEventListener('pointermove', seret);
canvas.addEventListener('pointerup', selesai);
canvas.addEventListener('pointercancel', selesai);
canvas { touch-action: none; } /* jari di canvas gak menggulir halaman */
Tanpa touch-action: none, di HP browser menganggap jari yang bergerak sebagai scroll: halamannya ikut bergeser, lalu browser mengirim pointercancel dan seretannya berhenti. preventDefault() di pointer event gak bisa mencegah scroll itu; touch-action yang bisa.
Dari layar ke koordinat canvas
e.clientX dan e.clientY adalah posisi pointer di jendela browser. canvas.getBoundingClientRect() memberi posisi dan ukuran canvas di jendela yang sama. Kurangi dengan pojok kirinya (rect.left, rect.top), lalu skalakan ke 300 satuan gambar:
function titikCanvas(e) {
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (300 / rect.width),
y: (e.clientY - rect.top) * (300 / rect.height),
};
}
Di HP, canvas tampil 240 px, dengan rect.left 40 dan rect.top 100. Pelanggan menyentuh di clientX 160, clientY 220:
| Langkah | x | y |
|---|---|---|
| Dikurangi pojok canvas | 160 - 40 = 120 | 220 - 100 = 120 |
| Dikali 300 / 240 = 1,25 | 150 | 150 |
Yang disentuh adalah (150, 150), tengah gambar. Itu jawaban tebakannya. Tanpa dikali 1,25, kodenya mengira (120, 120): meleset 30 px ke kiri atas, dan stiker yang disentuh gak terpilih.
Angka 300 di sini adalah satuan gambar, karena kita menggambar lewat setTransform(dpr, 0, 0, dpr, 0, 0) (lesson Canvas API Dasar). Kalau kamu menggambar langsung di pixel canvas asli, pakai canvas.width / rect.width.
Hit testing dengan matematika
Stiker bulat kena kalau jarak pointer ke pusatnya gak lebih dari jari-jarinya. Jaraknya dihitung dengan Pythagoras, dan supaya gak perlu Math.sqrt, bandingkan kuadratnya:
function kenaLingkaran(s, x, y) {
const dx = x - s.x;
const dy = y - s.y;
return dx * dx + dy * dy <= s.r * s.r;
}
function kenaKotak(t, x, y) {
return x >= t.x - t.lebar / 2 && x <= t.x + t.lebar / 2 &&
y >= t.y - 33 && y <= t.y + 1; // teks: 33 px di atas baseline, 1 px di bawahnya
}
Stiker di (235, 70) dengan r 28, disentuh di (250, 84): dx 15, dy 14, jadi 225 + 196 = 421. Batasnya 28 × 28 = 784. 421 ≤ 784, kena. Versi Math.sqrt(dx * dx + dy * dy) < r sama artinya, cuma sedikit lebih lambat.
Hit testing dengan isPointInPath
Badan cangkir berbentuk trapesium, dan rumusnya repot. Simpan bentuknya sebagai Path2D (path yang bisa dipakai ulang), lalu tanya canvas:
const badanCangkir = new Path2D();
badanCangkir.moveTo(-55, -45); // relatif ke pusat cangkir
badanCangkir.lineTo(55, -45);
badanCangkir.lineTo(45, 45);
badanCangkir.lineTo(-45, 45);
badanCangkir.closePath();
function kenaCangkir(c, x, y) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(c.x, c.y);
// titik dalam pixel canvas asli: transform gak berlaku untuknya
return ctx.isPointInPath(badanCangkir, x * dpr, y * dpr);
}
- Path-nya digeser dan diskalakan oleh transform saat itu, sama seperti waktu digambar.
- Titik
x,y-nya gak ikut transform: ia dibaca sebagai pixel canvas asli. Di layar DPR 2, tengah cangkir (150, 165) harus dikirim sebagai (300, 330). Kalau dikirim (150, 165), hasilnyafalse.
Menyeret
let dipegang = null;
let geser = { x: 0, y: 0 };
function mulaiSeret(e) {
const p = titikCanvas(e);
// dari yang paling atas: yang digambar terakhir dicek duluan
for (let i = desain.length - 1; i >= 0; i--) {
if (kena(desain[i], p.x, p.y)) {
dipegang = desain[i];
geser = { x: p.x - dipegang.x, y: p.y - dipegang.y };
canvas.setPointerCapture(e.pointerId);
return;
}
}
}
function seret(e) {
if (!dipegang) return;
const p = titikCanvas(e);
dipegang.x = p.x - geser.x;
dipegang.y = p.y - geser.y;
gambar();
}
function selesai() {
dipegang = null;
}
gesermenyimpan jarak pointer ke pusat benda. Tanpa itu, benda melompat sampai pusatnya tepat di bawah jari.setPointerCapturemembuatpointermovedanpointeruptetap dikirim ke canvas walau pointer keluar dari canvas.
Keyboard dan pembaca layar
Canvas gak punya elemen di dalamnya, jadi tombol Tab gak bisa memilih cangkir, dan pembaca layar cuma menemukan satu gambar. Yang ditawarkan sebagai gantinya:
- Tombol asli di samping canvas untuk tiap aksi: pilih benda, geser 10 px ke empat arah, ganti tinta. Tombol bisa difokus dan ditekan dengan Enter atau spasi.
- Status yang dibacakan:
<p aria-live="polite">Stiker dipilih, x 235, y 70, tinta coklat</p>, diperbarui tiap ada perubahan. - Deskripsi canvas:
role="img"danaria-labelyang menjelaskan desainnya. Teks di antara<canvas>dan</canvas>adalah cadangan untuk browser yang gak bisa menggambar canvas.
⚠️ Jebakan yang sering ketemu
- Lupa skala.
e.clientX - rect.leftsaja cuma benar kalau canvas tampil persis 300 px. Di HP 240 px, tekanannya meleset 30 px di tengah, lebih jauh di tepi kanan. - Titik isPointInPath gak dikali dpr. Path-nya ikut transform, titiknya enggak. Di layar retina, kirim
x * dprdany * dpr. - Mengecek dari bawah. Kalau stiker menumpuk di atas cangkir, cek dari yang digambar terakhir, supaya yang terpilih adalah yang terlihat di atas.
- Gak ada
touch-action: none. Di HP, seretan berubah jadi scroll dan berhenti di tengah jalan.
TL;DR: Canvas cuma menyimpan pixel, jadi simpan desainmu sebagai array objek dan gambar ulang dari situ. Pakai pointer events (pointerdown/move/up), dan ubah posisinya: (e.clientX - rect.left) * (300 / rect.width), begitu juga y dengan rect.top. Hit testing: lingkaran dengan dx * dx + dy * dy <= r * r, kotak dengan perbandingan x dan y, bentuk lain dengan isPointInPath(path, x * dpr, y * dpr), karena titiknya gak ikut transform. Seret dengan menyimpan jarak ke pusat dan setPointerCapture, cek dari benda paling atas, dan pasang touch-action: none. Untuk keyboard dan pembaca layar, sediakan tombol asli, status aria-live, dan aria-label di canvas.