Web Totals

Học từ Facebook #101: Hit-test trên canvas bằng phát lại path

Candela vẽ biểu đồ bằng canvas nên tự hit-test: ghi lệnh path rồi phát lại vào builder dựng vùng trúng và gần. Bản gốc mất rect, ném lỗi ở cung, nét đứt gãy.

hunghg255hunghg25538 min read
#facebook#canvas#chart#geometry#hit-testing
Học từ Facebook #101: Hit-test trên canvas bằng phát lại path

Biểu đồ vẽ bằng SVG thì mỗi cột, mỗi đường là một phần tử DOM: muốn biết chuột đang trỏ vào đâu chỉ cần mouseenter. Vẽ bằng <canvas> thì khác: canvas là một tấm ảnh. Bạn vẽ xong ba cột, một đường và mười điểm, trình duyệt chỉ còn biết "có một khối pixel 640×340". Chuột ở toạ độ (215, 186) là đang trên đường dữ liệu hay trên cột thứ hai, bạn phải tự trả lời.

Thư viện biểu đồ nội bộ của Facebook, Candela (đã gặp ở bài #64 về vạch trục), vẽ qua ART, một thư viện đồ hoạ có thể xuất ra canvas. Nó giải bài toán hit-test bằng một ý đơn giản: mỗi hình được mô tả bằng danh sách lệnh vẽ. Danh sách đó được phát lại hai lần, một lần để vẽ, một lần vào một bộ dựng có cùng API để tạo vùng hit-test (hit region).

Bài này đi qua cách làm đó, cách ART đổi cung thành Bézier rồi làm phẳng, và mấy lỗi đọc được trong bản gốc: rect() không tạo vùng nào, ellipse có lỗ ở giữa, nét đứt đi theo trục chứ không theo đường thẳng.

Tip

Tóm tắt

  • Mỗi hình ghi lại lệnh moveTo/lineTo/ellipse… thành [{ name, args }]. Vẽ thì phát lại lên path, hit-test thì phát lại vào một builder dựng đa giác/ellipse. Hai bên không bao giờ lệch nhau.
  • Vùng có hai ngưỡng: hover (trúng, vài px quanh nét) và proximity (ở gần, để hiện tooltip, snap). Query lọc nhanh bằng hộp bao, đảo thứ tự để hình vẽ sau (nằm trên) đứng đầu.
  • Bản gốc: rect() không bao giờ thành vùng, arc/bezierCurveTo ném "not implemented", ellipse tô kín có một lỗ bán kính bằng ngưỡng, hộp bao ellipse chỉ nới nửa ngưỡng nên vùng gần không bao giờ báo. drawDashedPath cộng dash vào x và y riêng rẽ nên đường chéo bị gãy.
  • Bản viết lại: bộ ghi có đủ API CanvasPath, đổi cung sang cubic với k = 4/3·tan(θ/4), làm phẳng bằng de Casteljau thích nghi, nét đứt cắt theo độ dài thật. Trình duyệt có sẵn isPointInPath/isPointInStroke khi chỉ cần trúng/trượt.

#Facebook làm thế nào?

#Ghi lệnh, phát lại hai lần: CandelaInteractiveMixin (thư mục 0 000A/000 CHART/0 Candela/)

Mỗi thành phần biểu đồ tương tác (đường, vùng, điểm) không vẽ thẳng lên canvas. Nó ghi lệnh vào this.commands:

addCommand(name, ...args) {
  this.commands.push({ name, args });
  return this;
}
getPath() {              // để vẽ: phát lại lên một Path của ART
  this.buildPath();
  return this.commands.reduce((path, { name, args }) => path[name](...args), new Path());
}
js

Khi biểu đồ ở chế độ tương tác và không đang chạy animation, _addHitRegion() gửi cùng danh sách đó cho regionManager.addHitRegion({ commands, fill, stroke, hoverThreshold, proximityThreshold, id, uid }). Mặc định hoverThreshold: 5, proximityThreshold: 10. Vì cùng một danh sách lệnh, hình vẽ ra và vùng hit-test luôn khớp nhau, kể cả khi dữ liệu đổi.

#Dựng vùng: CandelaRegionManager và CandelaPathRegionBuilder

addHitRegion tạo một builder rồi gọi lần lượt từng lệnh lên nó (builder[name].apply(builder, args)). Builder có API giống Path2D: moveTo, lineTo, line (tương đối), move, close, ellipse, rect. Khi build(), mỗi hình con thành một CandelaPolygonRegion hoặc CandelaEllipseRegion.

query(x, y) của manager (đổi tên biến):

query(x, y) {
  const candidates = this.inBounds(x, y);          // lọc nhanh bằng hộp bao
  return {
    hover: candidates.filter((r) => r.hoverTest(x, y)),
    proximity: candidates.filter((r) => r.proximityTest(x, y)),
  };
}
inBounds(x, y) {
  const out = [];
  for (const regions of this.regions.values())
    regions.forEach((r) => { const b = r.getBoundingRect(); if (inside(x, y, b)) out.push(r); });
  return out.reverse();                            // hình vẽ sau nằm trên, đứng đầu
}
js

Polygon kiểm "trúng" bằng hai cách: nếu tô kín thì đếm số lần tia ngang cắt cạnh (chẵn lẻ), nếu không thì đo khoảng cách tới từng đoạn bằng findDistanceToSegment (chiếu vuông góc, kẹp tham số vào [0, 1]) và so với ngưỡng. Hộp bao của từng đoạn được nới bằng ngưỡng lớn nhất, nên chỉ những đoạn gần chuột mới phải tính khoảng cách.

Ellipse kiểm bằng phương trình (dx·cos + dy·sin)²/rx² + (dx·sin − dy·cos)²/ry² ≤ 1, có hỗ trợ cung (góc đầu, góc cuối) và lỗ ở giữa (innerRadiusRatio, dùng cho biểu đồ donut). "Trúng" là nằm trong ellipse ngoài (bán kính + ngưỡng) và ngoài ellipse trong (bán kính × tỉ lệ − ngưỡng).

#Những gì builder không làm được

Đọc kỹ CandelaPathRegionBuilder, và chạy chính các module đó trong Node (thêm vài hàm giả cho EventEmitter, CandelaUtils), thấy bốn chỗ:

  1. rect() không tạo vùng nào. Lớp Rect gọi constructor cha, rồi dựng một polygon cục bộ và bỏ đi; bản thân đối tượng rect có 0 điểm. Polygon cục bộ đó còn thiếu một góc (chỉ có 3 điểm) và đổi chỗ rộng với cao. Cuối cùng build() chỉ nhận instanceof Polygon, mà Rect không phải. Chạy thử: rect(10, 10, 50, 100) cho 0 vùng. Trong bundle không thành phần nào gọi rect, nên lỗi này có lẽ chưa từng lộ.
  2. Cung và Bézier ném lỗi: arc, arcTo, bezierCurveTo, quadraticCurveTo đều throw new Error("Path2D.… not implemented!"). Vùng chỉ có thể là đa giác hoặc ellipse.
  3. Ellipse tô kín có một lỗ. Với ellipse tô kín, tỉ lệ lỗ mặc định là 0, nên bán kính trong là 0 − ngưỡng, một số âm. Công thức bình phương nó lên thành dương: một chấm tròn với hoverThreshold = 3 có một lỗ bán kính 3 px ở tâm, trỏ vào giữa chấm thì không trúng.
  4. Hộp bao ellipse chỉ nới nửa ngưỡng (x − 0.5·t, width + t), trong khi proximityTest nới đủ t. Cộng với lỗ ở trên, chấm tròn bán kính 8 px, ngưỡng gần 20 px chỉ "gần" khi chuột cách tâm từ 20 tới 28 px, mà hộp bao dừng ở 18 px. Chạy thử: vùng gần không bao giờ được báo.

Demo bật "Mô phỏng builder gốc" để thấy cả bốn: ba cột không phản hồi, vùng cong và donut báo lỗi, điểm cuối có lỗ và không có vùng gần.

#Cung thành Bézier: art/core/path (bundle All_Candela.js)

Thư viện ART (mã nguồn mở, được nhúng vào bundle) có phần mà builder thiếu. Nó đọc chuỗi d= kiểu SVG bằng một regex tách lệnh và số, và lặp lệnh ngầm: gặp số khi đang chờ lệnh thì dùng lại lệnh trước, riêng m thành l, M thành L, đúng như đặc tả SVG.

Cung (A) được đổi từ dạng "điểm đầu, điểm cuối" sang dạng "tâm, góc", phóng to bán kính nếu quá nhỏ, rồi cắt thành các đoạn không quá 90°. Mỗi đoạn là một cubic Bézier:

const parts = Math.ceil(Math.abs(sweep / (Math.PI / 2)));
const step = sweep / parts;
const k = 4 / 3 * Math.tan(step / 4);      // độ dài tay nắm cho cung `step` radian
for (let i = 0; i < parts; i++) {
  // c1 = điểm đầu + k·tiếp tuyến, c2 = điểm cuối − k·tiếp tuyến
  this.onBezierCurve(x, y, c1x, c1y, c2x, c2y, x = endX, y = endY);
}
js

Còn onBezierCurve mặc định chia đôi đệ quy (de Casteljau) cho tới khi hai điểm điều khiển cách dây cung chưa tới 0,1 px (bình phương < 0,01), rồi coi đoạn đó là thẳng. Q và T được nâng bậc thành cubic bằng (P0 + 2·C)/3.

#Nét đứt: CandelaUtils.art.drawDashedPath

Viền "dashed" và "dotted" của biểu đồ dùng hàm này:

const toward = (v, target, d) => v < target ? Math.min(v + d, target) : Math.max(v - d, target);
while (x !== x2 || y !== y2) {
  let d = nextDash();
  x = toward(x, x2, d); y = toward(y, y2, d);   // cộng d vào CẢ x và y
  path.lineTo(x, y);
  d = nextDash();
  x = toward(x, x2, d); y = toward(y, y2, d);
  path.moveTo(x, y);
}
js

Mỗi trục tiến d rồi tự kẹp ở đích. Với đường ngang hay dọc thì đúng. Với đường chéo thì sai hai kiểu: ở 45°, mỗi gạch dài d·√2 (14,14 px thay vì 10); ở góc khác, trục ngắn tới đích trước rồi đứng yên, đường thành gấp khúc. Từ (0, 0) tới (40, 10) với dash 5, nét đi chéo tới (10, 10) rồi đi ngang. Demo phần 2: ở 14°, nét lệch khỏi đường thẳng tới 51 px. Thêm một bẫy: dash bằng 0 (viền "dotted" với độ dày 0) thì x, y không bao giờ đổi, while chạy mãi.

flowchart LR
  A["Thành phần biểu đồ"] -->|"addCommand"| C["commands: name + args"]
  C -->|"getPath, phát lại"| P["Path của ART → canvas"]
  C -->|"addHitRegion, phát lại"| B["RegionBuilder"]
  B --> R1["PolygonRegion"]
  B --> R2["EllipseRegion"]
  M["mousemove x, y"] --> Q["query: lọc hộp bao, đảo thứ tự"]
  R1 --> Q
  R2 --> Q
  Q --> H["hover: trúng"]
  Q --> X["proximity: ở gần"]

#Bên trong trình duyệt: canvas không nhớ hình

Canvas 2D là chế độ tức thời (immediate mode): mỗi lệnh fill(), stroke() đổi pixel rồi quên. Không có cây đối tượng, không có sự kiện trên từng hình. SVG và DOM là chế độ giữ lại (retained mode): trình duyệt giữ cây hình học, nên tự hit-test được.

Trình duyệt cũng có hai hàm hit-test cho canvas: ctx.isPointInPath(path, x, y, fillRule) và ctx.isPointInStroke(path, x, y). Chúng nhận một Path2D và trả lời đúng/sai theo đúng hình học trình duyệt dùng để vẽ (kể cả cung, Bézier). Muốn có ngưỡng 5 px quanh nét thì đặt lineWidth = 10 rồi hỏi isPointInStroke. Demo có cột "isPointInPath / Stroke" để so.

Nhưng có ba thứ chúng không cho:

  • Khoảng cách. Chỉ có trúng/trượt. Vùng "gần" (để hiện tooltip khi chuột cách điểm 15 px) cần khoảng cách thật, hoặc hỏi thêm lần nữa với nét dày hơn.
  • Dữ liệu ngoài trình duyệt. Hit-test trong worker, trên server khi render ảnh, hay trong test không có canvas.
  • Thời ART. ART xuất ra canvas, SVG và VML (IE cũ). Một bộ hit-test bằng toán thuần dùng chung cho mọi đầu ra. Path2D cũng mới có ở mọi trình duyệt chính từ khoảng 2015 trở đi.

Đổi cung sang cubic là chuyện xấp xỉ. Một cung tròn 90° vẽ bằng cubic với tay nắm k = 4/3·tan(θ/4) (≈ 0,5523 khi θ = 90°) lệch bán kính thật tối đa khoảng 0,027%: bán kính 100 px thì lệch 0,03 px. Vì vậy cắt cung thành các đoạn ≤ 90° là đủ chính xác cho màn hình.

Làm phẳng cubic là bài toán khác: cần bao nhiêu đoạn thẳng để sai số dưới ε? Chia đôi đệ quy và dừng khi hai điểm điều khiển đủ gần dây cung là cách đơn giản và thích nghi: chỗ cong gắt chia nhiều, chỗ gần thẳng chia ít. Thanh trượt "sai số làm phẳng" trong demo cho thấy số đỉnh giảm từ hơn trăm xuống vài chục khi nới sai số.

#Viết lại

src/lib/hitRegions.ts gồm: một bộ ghi PathRecorder có đủ API CanvasPath (kể cả arc, ellipse, bezierCurveTo, quadraticCurveTo), làm phẳng mọi thứ thành đường gấp khúc; buildRegion dựng vùng; RegionIndex để query; và dashPolyline cắt nét đứt theo độ dài.

Cung và ellipse: chuẩn hoá góc quét như canvas, cắt thành đoạn ≤ 90°, mỗi đoạn một cubic.

src/lib/hitRegions.ts
  ellipse(cx: number, cy: number, rx: number, ry: number, rotation: number, start: number, end: number, ccw = false) {
    let sweep = end - start;
    // chuẩn hoá như canvas: quét tối đa một vòng, đúng chiều
    if (!ccw && sweep < 0) sweep = (sweep % TAU) + TAU;
    if (ccw && sweep > 0) sweep = (sweep % TAU) - TAU;
    if (Math.abs(sweep) > TAU) sweep = Math.sign(sweep) * TAU;
    const cos = Math.cos(rotation), sin = Math.sin(rotation);
    const at = (t: number, dx = 0, dy = 0): Pt => {
      const ex = rx * Math.cos(t) + dx, ey = ry * Math.sin(t) + dy;
      return { x: cx + ex * cos - ey * sin, y: cy + ex * sin + ey * cos };
    };
    const first = at(start);
    // canvas nối đường thẳng từ bút hiện tại tới điểm đầu cung
    if (this.current) this.lineTo(first.x, first.y);
    else this.moveTo(first.x, first.y);
    // chia cung thành các đoạn ≤ 90°, mỗi đoạn là một cubic với k = 4/3·tan(θ/4)
    const parts = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 2) - 1e-9));
    const step = sweep / parts;
    const k = (4 / 3) * Math.tan(step / 4);
    let t = start;
    for (let i = 0; i < parts; i++) {
      const t2 = t + step;
      const c1 = at(t, -k * rx * Math.sin(t), k * ry * Math.cos(t));
      const c2 = at(t2, k * rx * Math.sin(t2), -k * ry * Math.cos(t2));
      const p = at(t2);
      this.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p.x, p.y);
      t = t2;
    }
  }
ts

Làm phẳng cubic:

src/lib/hitRegions.ts
function flattenCubic(p0: Pt, p1: Pt, p2: Pt, p3: Pt, tol: number, out: Pt[], depth: number) {
  const flat = distToLine(p1, p0, p3) <= tol && distToLine(p2, p0, p3) <= tol;
  if (flat || depth > 16) {
    out.push(p3);
    return;
  }
  const mid = (a: Pt, b: Pt): Pt => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
  const a = mid(p0, p1), b = mid(p1, p2), c = mid(p2, p3);
  const d = mid(a, b), e = mid(b, c), m = mid(d, e);
  flattenCubic(p0, a, d, m, tol, out, depth + 1);
  flattenCubic(m, e, c, p3, tol, out, depth + 1);
}

function distToLine(p: Pt, a: Pt, b: Pt) {
  const dx = b.x - a.x, dy = b.y - a.y;
  const len = Math.hypot(dx, dy);
  if (len === 0) return Math.hypot(p.x - a.x, p.y - a.y);
  return Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / len;
}
ts

Điểm trong vùng tô: dùng số vòng quấn (winding number) để hỗ trợ cả nonzero lẫn evenodd, giống fill() của canvas.

src/lib/hitRegions.ts
export function pointInSubpaths(p: Pt, subpaths: Subpath[], rule: FillRule = 'nonzero') {
  let winding = 0;
  for (const { points } of subpaths) {
    const n = points.length;
    for (let i = 0; i < n; i++) {
      const a = points[i], b = points[(i + 1) % n];
      if (a.y <= p.y) {
        if (b.y > p.y && cross(a, b, p) > 0) winding++; // cạnh đi lên, p bên trái
      } else if (b.y <= p.y && cross(a, b, p) < 0) winding--; // cạnh đi xuống, p bên phải
    }
  }
  return rule === 'nonzero' ? winding !== 0 : (winding & 1) === 1;
}

const cross = (a: Pt, b: Pt, p: Pt) => (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y);
ts

Dựng vùng: phát lại hàm vẽ vào bộ ghi, nới hộp bao bằng đủ ngưỡng lớn nhất.

src/lib/hitRegions.ts
export function buildRegion(id: string, draw: (sink: PathSink) => void, opts: RegionOptions = {}, tolerance = 0.25): HitRegion {
  const rec = new PathRecorder(tolerance);
  draw(rec);
  const hoverThreshold = opts.hoverThreshold ?? 0;
  const proximityThreshold = opts.proximityThreshold ?? 0;
  const pad = Math.max(hoverThreshold, proximityThreshold);
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const s of rec.subpaths)
    for (const p of s.points) {
      x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
      x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
    }
  return {
    id,
    subpaths: rec.subpaths,
    fill: opts.fill ?? false,
    stroke: opts.stroke ?? !opts.fill,
    hoverThreshold,
    proximityThreshold,
    fillRule: opts.fillRule ?? 'nonzero',
    data: opts.data,
    // nới đủ cả ngưỡng: điểm cách nét đúng `pad` px vẫn lọt qua bước lọc nhanh
    bounds: { x: x0 - pad, y: y0 - pad, width: x1 - x0 + 2 * pad, height: y1 - y0 + 2 * pad },
  };
}
ts

Nét đứt theo độ dài thật, giữ pha qua các đỉnh:

src/lib/hitRegions.ts
export function dashPolyline(points: Pt[], pattern: number[], offset = 0): Pt[][] {
  const pat = pattern.length % 2 ? [...pattern, ...pattern] : pattern; // như setLineDash
  const total = pat.reduce((a, b) => a + b, 0);
  if (points.length < 2 || total <= 0 || pat.some((v) => v < 0 || !Number.isFinite(v))) return [points];
  const dashes: Pt[][] = [];
  let idx = 0;
  let left = pat[0];
  let phase = ((offset % total) + total) % total;
  while (phase > 0) { // bỏ qua phần offset
    const take = Math.min(phase, left);
    phase -= take; left -= take;
    if (left === 0) { idx = (idx + 1) % pat.length; left = pat[idx]; }
  }
  let current: Pt[] | null = idx % 2 === 0 ? [points[0]] : null;
  for (let i = 1; i < points.length; i++) {
    const a = points[i - 1], b = points[i];
    const len = Math.hypot(b.x - a.x, b.y - a.y);
    let pos = 0;
    while (pos < len) {
      const take = Math.min(left, len - pos);
      pos += take; left -= take;
      const t = pos / len; // tham số hoá theo độ dài, không theo từng trục
      const q = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
      if (current) current.push(q);
      if (left <= 1e-9) {
        if (current) { dashes.push(current); current = null; } else current = [q];
        idx = (idx + 1) % pat.length;
        left = pat[idx];
      }
    }
  }
  if (current && current.length > 1) dashes.push(current);
  return dashes;
}
ts
Xem toàn bộ src/lib/hitRegions.ts
src/lib/hitRegions.ts
/**
 * Hit-test cho hình vẽ trên canvas bằng cách "phát lại" lệnh path vào một bộ ghi
 * có cùng API với CanvasPath, làm phẳng đường cong thành đa giác, rồi hỏi
 * "chuột đang ở trên hình nào" mà không cần DOM cho từng hình.
 * Ý tưởng từ CandelaPathRegionBuilder / CandelaRegionManager và art/core/path (Facebook),
 * viết lại từ đầu, có thêm cung và Bézier.
 */

export interface Pt {
  x: number;
  y: number;
}

export interface Subpath {
  points: Pt[];
  closed: boolean;
}

/** Tập con của CanvasPath mà cả ctx thật lẫn bộ ghi đều có. */
export interface PathSink {
  moveTo(x: number, y: number): void;
  lineTo(x: number, y: number): void;
  bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): void;
  quadraticCurveTo(cx: number, cy: number, x: number, y: number): void;
  arc(x: number, y: number, r: number, start: number, end: number, ccw?: boolean): void;
  ellipse(x: number, y: number, rx: number, ry: number, rotation: number, start: number, end: number, ccw?: boolean): void;
  rect(x: number, y: number, w: number, h: number): void;
  closePath(): void;
}

const TAU = Math.PI * 2;

/** Ghi lệnh path và làm phẳng thành các đường gấp khúc. */
export class PathRecorder implements PathSink {
  readonly subpaths: Subpath[] = [];
  private current: Subpath | null = null;
  private pen: Pt = { x: 0, y: 0 };

  /** tolerance: sai số tối đa (px) giữa đường cong thật và đoạn thẳng thay thế. */
  constructor(private readonly tolerance = 0.25) {}

  get segmentCount() {
    return this.subpaths.reduce((n, s) => n + Math.max(0, s.points.length - 1) + (s.closed ? 1 : 0), 0);
  }

  moveTo(x: number, y: number) {
    this.current = { points: [{ x, y }], closed: false };
    this.subpaths.push(this.current);
    this.pen = { x, y };
  }

  lineTo(x: number, y: number) {
    if (!this.current) {
      this.moveTo(x, y); // giống canvas: lineTo đầu tiên đóng vai moveTo
      return;
    }
    this.current.points.push({ x, y });
    this.pen = { x, y };
  }

  bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number) {
    if (!this.current) this.moveTo(c1x, c1y);
    const p0 = this.pen;
    flattenCubic(p0, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }, this.tolerance, this.current!.points, 0);
    this.pen = { x, y };
  }

  quadraticCurveTo(cx: number, cy: number, x: number, y: number) {
    const p0 = this.pen;
    // nâng bậc: Q(P0, C, P1) = C(P0, (P0 + 2C)/3, (P1 + 2C)/3, P1)
    this.bezierCurveTo(
      (p0.x + 2 * cx) / 3, (p0.y + 2 * cy) / 3,
      (x + 2 * cx) / 3, (y + 2 * cy) / 3,
      x, y,
    );
  }

  arc(x: number, y: number, r: number, start: number, end: number, ccw = false) {
    this.ellipse(x, y, r, r, 0, start, end, ccw);
  }

  ellipse(cx: number, cy: number, rx: number, ry: number, rotation: number, start: number, end: number, ccw = false) {
    let sweep = end - start;
    // chuẩn hoá như canvas: quét tối đa một vòng, đúng chiều
    if (!ccw && sweep < 0) sweep = (sweep % TAU) + TAU;
    if (ccw && sweep > 0) sweep = (sweep % TAU) - TAU;
    if (Math.abs(sweep) > TAU) sweep = Math.sign(sweep) * TAU;
    const cos = Math.cos(rotation), sin = Math.sin(rotation);
    const at = (t: number, dx = 0, dy = 0): Pt => {
      const ex = rx * Math.cos(t) + dx, ey = ry * Math.sin(t) + dy;
      return { x: cx + ex * cos - ey * sin, y: cy + ex * sin + ey * cos };
    };
    const first = at(start);
    // canvas nối đường thẳng từ bút hiện tại tới điểm đầu cung
    if (this.current) this.lineTo(first.x, first.y);
    else this.moveTo(first.x, first.y);
    // chia cung thành các đoạn ≤ 90°, mỗi đoạn là một cubic với k = 4/3·tan(θ/4)
    const parts = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 2) - 1e-9));
    const step = sweep / parts;
    const k = (4 / 3) * Math.tan(step / 4);
    let t = start;
    for (let i = 0; i < parts; i++) {
      const t2 = t + step;
      const c1 = at(t, -k * rx * Math.sin(t), k * ry * Math.cos(t));
      const c2 = at(t2, k * rx * Math.sin(t2), -k * ry * Math.cos(t2));
      const p = at(t2);
      this.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p.x, p.y);
      t = t2;
    }
  }

  rect(x: number, y: number, w: number, h: number) {
    this.moveTo(x, y);
    this.lineTo(x + w, y);
    this.lineTo(x + w, y + h);
    this.lineTo(x, y + h);
    this.closePath();
  }

  closePath() {
    if (!this.current) return;
    this.current.closed = true;
    const start = this.current.points[0];
    // canvas: sau closePath bút về điểm đầu, lệnh tiếp theo mở subpath mới
    this.current = null;
    this.pen = { ...start };
  }
}

/** de Casteljau thích nghi: chia đôi tới khi hai control point đủ gần dây cung. */
function flattenCubic(p0: Pt, p1: Pt, p2: Pt, p3: Pt, tol: number, out: Pt[], depth: number) {
  const flat = distToLine(p1, p0, p3) <= tol && distToLine(p2, p0, p3) <= tol;
  if (flat || depth > 16) {
    out.push(p3);
    return;
  }
  const mid = (a: Pt, b: Pt): Pt => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
  const a = mid(p0, p1), b = mid(p1, p2), c = mid(p2, p3);
  const d = mid(a, b), e = mid(b, c), m = mid(d, e);
  flattenCubic(p0, a, d, m, tol, out, depth + 1);
  flattenCubic(m, e, c, p3, tol, out, depth + 1);
}

function distToLine(p: Pt, a: Pt, b: Pt) {
  const dx = b.x - a.x, dy = b.y - a.y;
  const len = Math.hypot(dx, dy);
  if (len === 0) return Math.hypot(p.x - a.x, p.y - a.y);
  return Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / len;
}

/** Khoảng cách từ p tới đoạn ab: chiếu vuông góc, kẹp t vào [0, 1]. */
export function distanceToSegment(p: Pt, a: Pt, b: Pt) {
  const dx = b.x - a.x, dy = b.y - a.y;
  const len2 = dx * dx + dy * dy;
  let t = len2 === 0 ? 0 : ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
  t = Math.max(0, Math.min(1, t));
  return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}

export type FillRule = 'nonzero' | 'evenodd';

/** Điểm có nằm trong vùng tô không. Subpath chưa đóng vẫn được tô như canvas (tự nối về đầu). */
export function pointInSubpaths(p: Pt, subpaths: Subpath[], rule: FillRule = 'nonzero') {
  let winding = 0;
  for (const { points } of subpaths) {
    const n = points.length;
    for (let i = 0; i < n; i++) {
      const a = points[i], b = points[(i + 1) % n];
      if (a.y <= p.y) {
        if (b.y > p.y && cross(a, b, p) > 0) winding++; // cạnh đi lên, p bên trái
      } else if (b.y <= p.y && cross(a, b, p) < 0) winding--; // cạnh đi xuống, p bên phải
    }
  }
  return rule === 'nonzero' ? winding !== 0 : (winding & 1) === 1;
}

const cross = (a: Pt, b: Pt, p: Pt) => (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y);

export interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export interface RegionOptions {
  fill?: boolean;
  stroke?: boolean;
  /** khoảng cách (px) tới nét vẫn tính là "trúng" */
  hoverThreshold?: number;
  /** khoảng cách (px) tính là "ở gần", dùng cho tooltip, snap */
  proximityThreshold?: number;
  fillRule?: FillRule;
  data?: unknown;
}

export interface HitRegion extends Required<Omit<RegionOptions, 'data'>> {
  id: string;
  subpaths: Subpath[];
  /** hộp bao đã nới ra bằng ngưỡng lớn nhất, dùng để lọc nhanh */
  bounds: Rect;
  data?: unknown;
}

export function buildRegion(id: string, draw: (sink: PathSink) => void, opts: RegionOptions = {}, tolerance = 0.25): HitRegion {
  const rec = new PathRecorder(tolerance);
  draw(rec);
  const hoverThreshold = opts.hoverThreshold ?? 0;
  const proximityThreshold = opts.proximityThreshold ?? 0;
  const pad = Math.max(hoverThreshold, proximityThreshold);
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const s of rec.subpaths)
    for (const p of s.points) {
      x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
      x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
    }
  return {
    id,
    subpaths: rec.subpaths,
    fill: opts.fill ?? false,
    stroke: opts.stroke ?? !opts.fill,
    hoverThreshold,
    proximityThreshold,
    fillRule: opts.fillRule ?? 'nonzero',
    data: opts.data,
    // nới đủ cả ngưỡng: điểm cách nét đúng `pad` px vẫn lọt qua bước lọc nhanh
    bounds: { x: x0 - pad, y: y0 - pad, width: x1 - x0 + 2 * pad, height: y1 - y0 + 2 * pad },
  };
}

/** Khoảng cách nhỏ nhất từ p tới nét của vùng (cạnh đóng của subpath kín cũng tính). */
export function distanceToOutline(p: Pt, region: HitRegion) {
  let best = Infinity;
  for (const { points, closed } of region.subpaths) {
    if (points.length === 1) best = Math.min(best, Math.hypot(p.x - points[0].x, p.y - points[0].y));
    for (let i = 1; i < points.length; i++) best = Math.min(best, distanceToSegment(p, points[i - 1], points[i]));
    if (closed && points.length > 2) best = Math.min(best, distanceToSegment(p, points[points.length - 1], points[0]));
  }
  return best;
}

export function hoverTest(region: HitRegion, p: Pt) {
  if (region.fill && pointInSubpaths(p, region.subpaths, region.fillRule)) return true;
  return distanceToOutline(p, region) <= region.hoverThreshold;
}

export function proximityTest(region: HitRegion, p: Pt) {
  if (region.fill && pointInSubpaths(p, region.subpaths, region.fillRule)) return true;
  return distanceToOutline(p, region) <= region.proximityThreshold;
}

const inRect = (p: Pt, r: Rect) => p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;

/** Danh sách vùng theo thứ tự vẽ; query trả về vùng nằm trên cùng trước. */
export class RegionIndex {
  private regions = new Map<string, HitRegion>();

  set(region: HitRegion) {
    this.regions.delete(region.id); // thêm lại = đưa lên trên cùng
    this.regions.set(region.id, region);
  }

  delete(id: string) {
    return this.regions.delete(id);
  }

  clear() {
    this.regions.clear();
  }

  get size() {
    return this.regions.size;
  }

  query(p: Pt): { hover: HitRegion[]; proximity: HitRegion[]; candidates: number } {
    const candidates = [...this.regions.values()].filter((r) => inRect(p, r.bounds)).reverse();
    return {
      hover: candidates.filter((r) => hoverTest(r, p)),
      proximity: candidates.filter((r) => proximityTest(r, p)),
      candidates: candidates.length,
    };
  }
}

/**
 * Cắt một đường gấp khúc thành các gạch theo độ dài thật.
 * pattern = [gạch, khoảng, gạch, khoảng…]; pha được giữ qua các đỉnh.
 */
export function dashPolyline(points: Pt[], pattern: number[], offset = 0): Pt[][] {
  const pat = pattern.length % 2 ? [...pattern, ...pattern] : pattern; // như setLineDash
  const total = pat.reduce((a, b) => a + b, 0);
  if (points.length < 2 || total <= 0 || pat.some((v) => v < 0 || !Number.isFinite(v))) return [points];
  const dashes: Pt[][] = [];
  let idx = 0;
  let left = pat[0];
  let phase = ((offset % total) + total) % total;
  while (phase > 0) { // bỏ qua phần offset
    const take = Math.min(phase, left);
    phase -= take; left -= take;
    if (left === 0) { idx = (idx + 1) % pat.length; left = pat[idx]; }
  }
  let current: Pt[] | null = idx % 2 === 0 ? [points[0]] : null;
  for (let i = 1; i < points.length; i++) {
    const a = points[i - 1], b = points[i];
    const len = Math.hypot(b.x - a.x, b.y - a.y);
    let pos = 0;
    while (pos < len) {
      const take = Math.min(left, len - pos);
      pos += take; left -= take;
      const t = pos / len; // tham số hoá theo độ dài, không theo từng trục
      const q = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
      if (current) current.push(q);
      if (left <= 1e-9) {
        if (current) { dashes.push(current); current = null; } else current = [q];
        idx = (idx + 1) % pat.length;
        left = pat[idx];
      }
    }
  }
  if (current && current.length > 1) dashes.push(current);
  return dashes;
}

/**
 * Cách của CandelaUtils.art.drawDashedPath (viết lại để so sánh): cộng `dash` vào x và y
 * độc lập, mỗi trục tự kẹp về đích. Có trần số vòng vì dash = 0 sẽ lặp vô hạn.
 */
export function axisDashedPath(from: Pt, to: Pt, pattern: number[], maxSteps = 10_000): { dashes: Pt[][]; steps: number; stuck: boolean } {
  const toward = (v: number, target: number, d: number) => (v < target ? Math.min(v + d, target) : Math.max(v - d, target));
  let x = from.x, y = from.y, i = 0, steps = 0;
  const next = () => pattern[i++ % pattern.length];
  const dashes: Pt[][] = [];
  while ((x !== to.x || y !== to.y) && steps < maxSteps) {
    steps++;
    const start = { x, y };
    let d = next();
    x = toward(x, to.x, d); y = toward(y, to.y, d);
    dashes.push([start, { x, y }]);
    d = next();
    x = toward(x, to.x, d); y = toward(y, to.y, d);
  }
  return { dashes, steps, stuck: steps >= maxSteps };
}

export function polylineLength(points: Pt[]) {
  let n = 0;
  for (let i = 1; i < points.length; i++) n += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
  return n;
}
ts

#Đi qua code

  • Một hàm vẽ, hai "bút". Hình trong demo là draw(p: PathSink). Gọi draw(ctx) để vẽ, draw(new PathRecorder()) để dựng vùng, draw(new Path2D()) để hỏi trình duyệt. PathSink là tập con chung của cả ba, nên TypeScript bắt lỗi nếu hình dùng lệnh mà bộ ghi chưa có.
  • ellipse: canvas nối một đường thẳng từ bút hiện tại tới điểm đầu cung, nên bộ ghi cũng lineTo điểm đó nếu đang có subpath. Lát donut trong demo dựa vào hành vi này: cung ngoài, rồi cung trong ngược chiều, closePath.
  • Tay nắm của cubic: tiếp tuyến của ellipse tại góc t là (−rx·sin t, ry·cos t). c1 = điểm đầu + k·tiếp tuyến, c2 = điểm cuối − k·tiếp tuyến, rồi cả hai mới được xoay theo rotation. Bản gốc của builder bỏ qua rotation; bản này giữ.
  • flattenCubic: điều kiện dừng là khoảng cách hai điểm điều khiển tới dây cung ≤ tol. Có trần độ sâu 16 cho đầu vào lạ (NaN, toạ độ khổng lồ).
  • pointInSubpaths: cạnh đi lên mà điểm nằm bên trái thì +1, cạnh đi xuống mà điểm nằm bên phải thì −1. Subpath chưa đóng vẫn được coi là kín, giống cách canvas tô.
  • proximityTest cũng tính "ở trong": chuột ở giữa một cột thì cột đó vừa trúng vừa gần. Bản gốc chỉ đo khoảng cách tới cạnh.
  • RegionIndex.set xoá rồi thêm lại, nên cập nhật một vùng cũng đưa nó lên trên cùng, đúng với thứ tự vẽ lại.
  • dashPolyline: biến left là phần còn lại của gạch hoặc khoảng hiện tại, đi qua đỉnh thì mang sang đoạn sau. Mảng lẻ được nhân đôi như setLineDash([5]) thành [5, 5]. Dash 0 hay số âm thì trả nguyên đường liền, không lặp vô hạn.

Khác bản gốc:

  • Có arc, ellipse có rotation, bezierCurveTo, quadraticCurveTo (làm phẳng thành đa giác); rect là một subpath kín 4 góc.
  • Không có ellipse riêng: mọi thứ là đa giác. Đổi lại, lỗ donut là một subpath ngược chiều, như khi vẽ bằng canvas.
  • Hộp bao nới đủ ngưỡng; không có bán kính âm.
  • Nét đứt tham số hoá theo độ dài; dash 0 không treo.

#Tại sao không làm cách khác?

  • Chỉ dùng isPointInPath/isPointInStroke: đúng và nhanh, nên dùng nếu chỉ cần trúng/trượt trên canvas thật. Muốn vùng "gần" thì phải hỏi thêm với nét dày hơn, và không có khoảng cách để chọn điểm gần nhất.
  • Canvas ẩn tô màu theo id (mỗi hình một màu, đọc pixel dưới chuột): O(1) mỗi lần query, hợp khi có hàng nghìn hình. Nhưng phải vẽ hai lần, khử răng cưa (anti-aliasing) làm sai màu ở mép, và getImageData buộc GPU đọc ngược về CPU.
  • Dựng DOM trong suốt đè lên canvas: dễ cho trợ năng (focus, aria) nhưng nặng khi nhiều điểm.
  • Thư viện có sẵn (Konva, PixiJS, ECharts): đều tự hit-test; đáng dùng nếu bạn chưa có tầng vẽ riêng.

#Edge case và cạm bẫy

  • Nhiều hình chồng nhau: luôn trả về theo thứ tự vẽ ngược. Hình nằm dưới vẫn có trong proximity; giao diện thường chỉ lấy phần tử đầu của hover.
  • Transform: nếu vẽ sau ctx.scale(dpr, dpr) hay xoay, toạ độ chuột phải đổi ngược trước khi query. isPointInPath dùng toạ độ chưa transform của canvas, còn path thì bị transform theo ma trận hiện tại; dễ nhầm.
  • Nét dày: vùng stroke nên có hoverThreshold ≥ lineWidth / 2, không thì trỏ vào mép nét đang hiện mà không trúng.
  • Lỗ trong hình: nonzero và evenodd cho kết quả khác nhau khi subpath trong cùng chiều với subpath ngoài. Dùng cùng quy tắc với lúc fill().
  • Hiệu năng: query duyệt mọi vùng (lọc hộp bao là O(n)). Vài trăm vùng không sao; hàng vạn điểm thì nên có lưới chia ô hoặc cây R (R-tree), và chỉ query trong requestAnimationFrame.
  • Animation: bản gốc không dựng vùng khi đang animation; bạn cũng nên xoá vùng cũ hoặc tạm tắt hit-test, nếu không vùng nằm ở vị trí cũ.

#Dùng trong dự án

import { buildRegion, RegionIndex, type PathSink } from './hitRegions';

const index = new RegionIndex();
const shapes = points.map((p, i) => ({
  id: `pt-${i}`,
  draw: (s: PathSink) => s.arc(p.x, p.y, 4, 0, Math.PI * 2),
}));

function render(ctx: CanvasRenderingContext2D) {
  index.clear();
  for (const sh of shapes) {
    ctx.beginPath(); sh.draw(ctx); ctx.fill();
    index.set(buildRegion(sh.id, sh.draw, { fill: true, hoverThreshold: 2, proximityThreshold: 16 }));
  }
}

canvas.addEventListener('pointermove', (e) => {
  const { hover, proximity } = index.query({ x: e.offsetX, y: e.offsetY });
  canvas.style.cursor = hover.length ? 'pointer' : '';
  showTooltip(proximity[0]?.id);
});
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { buildRegion, dashPolyline, PathRecorder, pointInSubpaths, polylineLength, RegionIndex } from './hitRegions';

describe('PathRecorder', () => {
  it('rect thành subpath kín 4 góc', () => {
    const r = new PathRecorder();
    r.rect(10, 10, 50, 100);
    expect(r.subpaths[0].points).toHaveLength(4);
    expect(r.subpaths[0].closed).toBe(true);
  });

  it('cung tròn sau khi làm phẳng nằm sát bán kính', () => {
    const r = new PathRecorder(0.1);
    r.arc(0, 0, 100, 0, Math.PI * 2);
    for (const p of r.subpaths[0].points) expect(Math.abs(Math.hypot(p.x, p.y) - 100)).toBeLessThan(0.2);
  });
});

describe('hit-test', () => {
  it('chấm tròn trúng ở tâm, gần ở 15 px', () => {
    const dot = buildRegion('dot', (s) => s.ellipse(100, 100, 8, 8, 0, 0, Math.PI * 2), { fill: true, hoverThreshold: 3, proximityThreshold: 20 });
    const index = new RegionIndex();
    index.set(dot);
    expect(index.query({ x: 100, y: 100 }).hover.map((r) => r.id)).toEqual(['dot']);
    expect(index.query({ x: 123, y: 100 }).proximity.map((r) => r.id)).toEqual(['dot']);
  });

  it('hình vẽ sau đứng đầu', () => {
    const index = new RegionIndex();
    index.set(buildRegion('under', (s) => s.rect(0, 0, 100, 100), { fill: true }));
    index.set(buildRegion('over', (s) => s.rect(50, 50, 100, 100), { fill: true }));
    expect(index.query({ x: 75, y: 75 }).hover.map((r) => r.id)).toEqual(['over', 'under']);
  });

  it('evenodd tạo lỗ, nonzero thì không khi cùng chiều', () => {
    const r = new PathRecorder();
    r.rect(0, 0, 100, 100);
    r.rect(25, 25, 50, 50);
    expect(pointInSubpaths({ x: 50, y: 50 }, r.subpaths, 'evenodd')).toBe(false);
    expect(pointInSubpaths({ x: 50, y: 50 }, r.subpaths, 'nonzero')).toBe(true);
  });
});

describe('dashPolyline', () => {
  it('gạch dài đúng dash trên đường chéo', () => {
    const d = dashPolyline([{ x: 0, y: 0 }, { x: 300, y: 75 }], [10]);
    expect(polylineLength(d[0])).toBeCloseTo(10);
  });
  it('dash 0 không treo', () => {
    expect(dashPolyline([{ x: 0, y: 0 }, { x: 10, y: 0 }], [0])).toHaveLength(1);
  });
});
ts

#So với thư viện và chuẩn web

  • CanvasRenderingContext2D.isPointInPath / isPointInStroke với Path2D: có ở mọi trình duyệt chính (tính tới 10/2026). Nhanh, chính xác, nhưng chỉ trả lời trúng/trượt và cần canvas thật (OffscreenCanvas trong worker cũng có).
  • Hit region API (ctx.addHitRegion) từng nằm trong đặc tả canvas, cho phép gắn id và phần tử trợ năng vào từng vùng. Nó đã bị gỡ khỏi đặc tả và khỏi các trình duyệt thử nghiệm; tên addHitRegion của Candela có lẽ lấy cảm hứng từ đó.
  • Konva, Fabric.js: hit-test bằng canvas ẩn tô màu theo id. PixiJS: hit-test bằng hình học (hitArea). ECharts (bản canvas): hình học + isPointInPath.
  • SVG: nếu số hình ít (vài trăm), SVG cho sẵn sự kiện, trợ năng và CSS hover; canvas chỉ đáng khi số điểm lớn.
  • Bài liên quan: #64 vạch trục "đẹp" của Candela, #82 ma trận xoay sticker.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz