1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザの手書きノートで Apple Pencil の筆圧を取る — `MouseEvent` から `PointerEvent` に乗り換える話と、`getCoalescedEvents()` で 240 Hz サンプリングを取りこぼさない話

1
Posted at

Web に手書きノート機能を載せるとき、addEventListener("mousedown", ...) で書き始めると Apple Pencil の筆圧が全部捨てられるtouchstart だけで書き始めると マウスでテストできないPointerEvent を使っても 240 Hz でサンプリングしている iPad のサブフレームを取りこぼす、という三段の罠がある。300 行のブラウザ手書きノートを書きながら、その三段全部を解いた話。

canvas-notebook の画面: ライト背景の手書きキャンバスに、cyan の波線が筆圧で太さの変わるストローク、緑のスクリブル、赤い "Hello" の文字、紫の圧力ランプ (左→右で太→細→太と変化)、オレンジの笑顔が描かれている。下部に POINTERTYPE / PRESSURE / TILT / STROKES のステータス表示

🌐 デモ: https://sen.ltd/portfolio/canvas-notebook/
📦 GitHub: https://github.com/sen-ltd/canvas-notebook

なぜ MouseEvent / TouchEvent を捨てるか

歴史的な経緯で、Web の入力イベントは 3 系統に分かれている:

  • MouseEvent (mousedown / mousemove / mouseup) — マウス専用
  • TouchEvent (touchstart / touchmove / touchend) — タッチ専用、マルチタッチ対応
  • PointerEvent (pointerdown / pointermove / pointerup) — 上記を統一する後発の API

PointerEventマウス・タッチ・スタイラスを同じ event オブジェクトで扱えるe.pointerType"mouse" / "touch" / "pen" を見分け、e.pressure で筆圧、e.tiltX / e.tiltY で傾き、e.twist で軸の回転 (Apple Pencil 2 以降は対応していない) まで取れる。

加えて setPointerCapture(pointerId)画面外にドラッグしても release イベントが拾えるmousemove ベースだと、ボタンを押したまま window を出るとイベントが来なくなって「描画中の状態」が壊れる、というあるあるバグが PointerEvent では起きない。

e.pressure の落とし穴

PointerEvent.pressure は 0〜1 の正規化値を返すが、デバイスによって値の意味が違う:

pointerType pressure の挙動
mouse 押してる間 0.5 固定、離すと 0
touch (iOS) force を返す (3D Touch 廃止後も内部 API は残っている)
touch (Android) 多くの端末で 0 固定
pen (Apple Pencil) 0.0〜1.0 の連続値、240 Hz でサンプリング
pen (Surface Pen / Wacom) 0.0〜1.0 の連続値、120〜240 Hz

つまり「e.pressure を読んで線の太さに掛ける」だけだと マウスで描いたとき太さが半分になる ということ。fallback が必要:

export function pressureToWidth(pressure, baseWidth, options = {}) {
  const { min = 0.4, max = 1.6, fallback = 0.5 } = options;
  const p = pressure > 0 ? pressure : fallback;
  // [0, 1] → [min, max] に線形マップ、out-of-range は clamp
  const factor = min + (max - min) * Math.max(0, Math.min(1, p));
  return baseWidth * factor;
}

pressure === 0 のときだけ fallback を使う (押下していない / 検出不能を意味する)。0.001 でも来ていれば実値として扱う — そうしないと、低圧で書く iPad ユーザーが fallback と本物の境界をまたぐたびにストロークの太さが急変する。

ユニットテストで境界条件を全部固定:

test("pressureToWidth uses fallback only for pressure === 0", () => {
  // 0.001 → 実値、ほぼ最小幅 (min × base = 4)
  const w = pressureToWidth(0.001, 10);
  assert.ok(w < 10);
  assert.ok(w > 4);
});

test("pressureToWidth clamps out-of-range input", () => {
  // 一部のドライバが pressure > 1 を返すバグ報告がある。crash させない
  assert.equal(pressureToWidth(99, 10), 16);
  assert.equal(pressureToWidth(-1, 10), 10);  // fallback path
});

getCoalescedEvents() で取りこぼしゼロ

ここが本記事の核。Apple Pencil は 240 Hz でサンプリングする。一方ブラウザの requestAnimationFrame60 Hz。普通に pointermove を聞いていると、1 frame の間に OS が捕まえた 4 回分のサンプルのうち最後の 1 回しか拾えない。素早く線を引くと、サンプル間の距離が 8〜16 px になり、その間の curve が直線で繋がれて見栄えが悪い。

PointerEvent.getCoalescedEvents()過去 1 frame で coalesce された全サブサンプルを返す:

function onPointerMove(e) {
  if (state.activePointerId !== e.pointerId) return;
  // フォールバック: 未対応 / 空配列 を返すブラウザでは e 自身を 1 サンプル扱い
  let samples = [e];
  if (typeof e.getCoalescedEvents === "function") {
    const coalesced = e.getCoalescedEvents();
    if (coalesced && coalesced.length > 0) samples = coalesced;
  }
  for (const s of samples) {
    state.active.points.push(eventToPoint(s));
  }
  redraw();
}

これで Apple Pencil で速く描いても 240 Hz 全部の点が記録される。同じ frame 内なので画面更新は 60 Hz でいいが、データとして失われない。

注意点: 合成イベント (テストで dispatchEvent(new PointerEvent(...))) では getCoalescedEvents()空配列を返す ので、length > 0 のガードが要る (これを忘れて画面に何も描かれない 1 時間を溶かした)。

RDP で点列を間引く

240 Hz × 数秒のストロークは数百〜数千点になる。直線部分は 2 点で十分なので、ストローク完了時に Ramer-Douglas-Peucker で間引く:

export function simplifyStroke(points, tolerance) {
  if (points.length < 3) return points.slice();
  const t2 = tolerance * tolerance;
  const keep = new Array(points.length).fill(false);
  keep[0] = true;
  keep[points.length - 1] = true;

  function recurse(start, end) {
    let maxDist = 0;
    let idx = -1;
    for (let i = start + 1; i < end; i++) {
      const d = perpendicularDistanceSq(points[i], points[start], points[end]);
      if (d > maxDist) { maxDist = d; idx = i; }
    }
    if (maxDist > t2 && idx !== -1) {
      keep[idx] = true;
      recurse(start, idx);
      recurse(idx, end);
    }
  }
  recurse(0, points.length - 1);

  return points.filter((_, i) => keep[i]);
}

tolerance 0.4 px なら見た目は変わらないが点数は 1/3〜1/5 に減る。SVG export のサイズも比例して小さくなる。

可変幅レンダリング

各セグメントの太さを両端の筆圧の平均から決める:

function drawStroke(stroke, ctx) {
  const pts = stroke.points;
  ctx.strokeStyle = stroke.color;
  for (let i = 1; i < pts.length; i++) {
    const a = pts[i - 1], b = pts[i];
    const wa = pressureToWidth(a.pressure, stroke.baseWidth);
    const wb = pressureToWidth(b.pressure, stroke.baseWidth);
    ctx.lineWidth = (wa + wb) / 2;
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }
}

lineCap: "round" + lineJoin: "round" を 1 度だけ設定しておけば、各セグメントの先端が円形にレンダリングされて、隣接セグメントと滑らかに繋がる。quadraticCurveTo でスムージングしようとすると 可変 lineWidth と組み合わせたときに control point の管理がややこしくなる (実際 1 度書いて円が閉じない不具合を踏んだ)、ので segments + round caps の単純化で十分綺麗。

SVG エクスポートの妥協点

PNG は canvas.toDataURL("image/png") 1 行で出る。SVG はストロークデータから path を組み立てる:

export function strokesToSvg(strokes, width, height, background = null) {
  const bg = background === null
    ? ""
    : `<rect x="0" y="0" width="${width}" height="${height}" fill="${escapeAttr(background)}"/>`;
  const paths = strokes.map((s) => {
    const d = pointsToSvgPath(s.points);
    const meanWidth = averageWidth(s);
    return `<path d="${d}" fill="none" stroke="${escapeAttr(s.color)}" stroke-width="${fmt(meanWidth)}" stroke-linecap="round" stroke-linejoin="round"/>`;
  }).join("");
  return `<?xml version="1.0" encoding="UTF-8"?><svg ...>${bg}${paths}</svg>`;
}

ここで 妥協がある: SVG の <path stroke-width>1 ストロークに 1 値。Canvas 側でやっていた「セグメントごとに太さを変える」ことができない。完全に再現するなら polygon outline を作る (各点で stroke の輪郭を計算して <path> を fill=true で描く) が、データサイズが 5〜10 倍になるので、ストローク内の平均幅で 1 値に潰す ことにした。

color 属性の escape も忘れずに。color は color-picker からしか入らないが、防御的にエスケープしてテストでも固定:

test("strokesToSvg escapes the color attribute against injection", () => {
  const svg = strokesToSvg(
    [{ color: '"><script>', baseWidth: 1, points: [pt(0, 0)] }],
    10, 10,
  );
  assert.ok(!svg.includes("<script>"));
  assert.ok(svg.includes("&lt;script&gt;"));
});

まとめ

  • MouseEvent / TouchEvent は捨てるPointerEvent 1 系統で全部処理できる
  • e.pressure は信用しすぎない。0 のときは fallback、out-of-range は clamp
  • getCoalescedEvents() を呼ばないと Apple Pencil の 240 Hz が 60 Hz に間引かれる。返り値が空のときは [e] にフォールバック (合成イベントで死なないように)
  • 可変幅は segments + round caps の素朴版で十分綺麗quadraticCurveTo と混ぜると path 管理が複雑化
  • SVG export は per-stroke 1 太さ。完全再現は polygon outline、要らないなら平均幅で OK

ソース: https://github.com/sen-ltd/canvas-notebook — MIT、合計 ~300 行、18 ユニットテスト、ビルド不要、依存ゼロ。


🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?