2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【JavaScript】画像の「透明な余白」はクリックさせない!Canvasを使ったピクセル単位の当たり判定実装

Last updated at Posted at 2025-11-30

こんにちは、roll1226です
JavaScript Advent Calendar 2025の1日目の記事をお届けします
初めてのアドベントカレンダーになりますので温かい目で見ていただければ嬉しいです

画像にクリックイベントを持たせて実装した経験を持つ方が多いかと思います
Webサイトでキャラクターやロゴなど不定形な画像にクリックイベントを持たせて実装したい時に<img>タグでは画像の「四角い枠全体」がクリック範囲になってしまいます
これでは透明な余白をクリックしても反応してしまい、ユーザー体験としては不自然に感じるかと思わます
そんな時にHTML5 Canvas APIを活用した、画像の不透明な部分だけをクリック可能にするJavaScriptの実装方法を書いていきます

全体像

/**
 * 透過PNG画像のクリック判定クラス
 * アルファマップをキャッシュして高速なクリック判定を提供
 */
class TransparentImageClickHandler {
  /**
   * @typedef {Object} ClickHandlerOptions
   * @property {number} [alphaThreshold=10] - アルファ値の閾値(0-255)
   * @property {string} [href] - クリック時の遷移先URL
   * @property {Function} [onClick] - クリック時のコールバック関数
   */

  /**
   * @typedef {Object} PixelCoordinate
   * @property {number} x - X座標
   * @property {number} y - Y座標
   */

  /**
   * コンストラクタ
   * @param {HTMLImageElement} img - 対象の画像要素
   * @param {ClickHandlerOptions} [options={}] - オプション設定
   */
  constructor(img, options = {}) {
    this.img = img;
    this.threshold = options.alphaThreshold ?? 10;
    this.href = options.href ?? img.dataset.href ?? null;
    this.onClick = options.onClick;

    this.hitmap = null;
    this.width = 0;
    this.height = 0;
    this.tainted = false;
    this.rafId = null;
    this.latestEvent = null;

    this.init();
  }

  /**
   * 初期化処理
   * @private
   */
  init() {
    if (this.img.complete && this.img.naturalWidth !== 0) {
      this.buildHitmap();
    } else {
      this.img.addEventListener("load", () => this.buildHitmap());
    }

    this.setupEventListeners();
  }

  /**
   * イベントリスナーの設定
   * @private
   */
  setupEventListeners() {
    this.img.addEventListener("pointermove", (evt) => this.handleMove(evt));
    this.img.addEventListener("pointerleave", () => this.resetCursor());
    this.img.addEventListener("click", (evt) => this.handleClick(evt));
  }

  /**
   * アルファマップを構築
   * @private
   */
  buildHitmap() {
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d", { willReadFrequently: true });

    this.width = this.img.naturalWidth;
    this.height = this.img.naturalHeight;
    canvas.width = this.width;
    canvas.height = this.height;

    try {
      ctx.drawImage(this.img, 0, 0, this.width, this.height);
      const imageData = ctx.getImageData(0, 0, this.width, this.height);
      this.hitmap = this.createHitmapFromImageData(imageData.data);
    } catch (error) {
      console.warn("Canvas tainted or image unreadable:", error);
      this.tainted = true;
      this.hitmap = null;
    }
  }

  /**
   * ImageDataからヒットマップを作成
   * @param {Uint8ClampedArray} data - ImageDataの配列
   * @returns {Uint8Array} ヒットマップ
   * @private
   */
  createHitmapFromImageData(data) {
    const hitmap = new Uint8Array(this.width * this.height);

    for (let i = 0, j = 3; j < data.length; i++, j += 4) {
      hitmap[i] = data[j] > this.threshold ? 1 : 0;
    }

    return hitmap;
  }

  /**
   * マウス位置から画像ピクセル座標を取得
   * @param {PointerEvent|TouchEvent} event - イベントオブジェクト
   * @returns {PixelCoordinate|null} ピクセル座標またはnull
   * @private
   */
  getPixelCoordinate(event) {
    const rect = this.img.getBoundingClientRect();
    const clientX = event.clientX ?? event.touches?.[0]?.clientX;
    const clientY = event.clientY ?? event.touches?.[0]?.clientY;

    if (clientX == null || clientY == null) return null;

    const x = Math.floor((clientX - rect.left) * (this.width / rect.width));
    const y = Math.floor((clientY - rect.top) * (this.height / rect.height));

    if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
      return null;
    }

    return { x, y };
  }

  /**
   * 指定座標のピクセルが不透明かどうかを判定
   * @param {number} x - X座標
   * @param {number} y - Y座標
   * @returns {boolean} 不透明な場合true
   * @private
   */
  isOpaqueAt(x, y) {
    if (this.tainted) return true; // フォールバック
    if (!this.hitmap) return false;
    return this.hitmap[y * this.width + x] === 1;
  }

  /**
   * マウス移動イベントのハンドラ(requestAnimationFrameで最適化)
   * @param {PointerEvent} event - ポインターイベント
   * @private
   */
  handleMove(event) {
    this.latestEvent = event;

    if (!this.rafId) {
      this.rafId = requestAnimationFrame(() => {
        this.rafId = null;
        if (!this.latestEvent) return;

        const coordinate = this.getPixelCoordinate(this.latestEvent);
        if (!coordinate) return;

        const isOpaque = this.isOpaqueAt(coordinate.x, coordinate.y);
        this.setCursor(isOpaque ? "pointer" : "default");
      });
    }
  }

  /**
   * クリックイベントのハンドラ
   * @param {PointerEvent} event - ポインターイベント
   * @private
   */
  handleClick(event) {
    const coordinate = this.getPixelCoordinate(event);
    if (!coordinate) return;

    if (this.isOpaqueAt(coordinate.x, coordinate.y)) {
      this.executeClickAction(event);
    } else {
      this.preventClickPropagation(event);
    }
  }

  /**
   * クリックアクションを実行
   * @param {PointerEvent} event - ポインターイベント
   * @private
   */
  executeClickAction(event) {
    if (this.onClick) {
      this.onClick(event);
    } else if (this.href) {
      window.location.href = this.href;
    }
  }

  /**
   * クリックイベントの伝播を防ぐ
   * @param {PointerEvent} event - ポインターイベント
   * @private
   */
  preventClickPropagation(event) {
    event.preventDefault();
    event.stopPropagation();
  }

  /**
   * カーソルスタイルを設定
   * @param {string} cursor - カーソルスタイル
   * @private
   */
  setCursor(cursor) {
    this.img.style.cursor = cursor;
  }

  /**
   * カーソルをデフォルトに戻す
   * @private
   */
  resetCursor() {
    this.setCursor("default");
  }

  /**
   * イベントリスナーを削除してクリーンアップ
   * @public
   */
  destroy() {
    if (this.rafId) {
      cancelAnimationFrame(this.rafId);
      this.rafId = null;
    }

    this.img.removeEventListener("pointermove", this.handleMove);
    this.img.removeEventListener("pointerleave", this.resetCursor);
    this.img.removeEventListener("click", this.handleClick);

    this.hitmap = null;
    this.latestEvent = null;
  }
}

/**
 * 画像要素に透過クリック判定を設定するヘルパー関数
 * @param {HTMLImageElement} img - 対象の画像要素
 * @param {ClickHandlerOptions} [options={}] - オプション設定
 * @returns {TransparentImageClickHandler} ハンドラーインスタンス
 */
function setupClickableImage(img, options = {}) {
  return new TransparentImageClickHandler(img, options);
}

実際に動作させているサイトになります

仕組みの概要

実装のアイデアは下記になります

  1. マウスポインタの位置が「不透明」な場所にある時だけクリックを有効化

1. 準備フェーズ(ページ読み込み時)

画像が読み込まれた直後に、裏側で「当たり判定用の地図(ヒットマップ)」を作成します。

  1. 対象の画像をメモリ上のCanvasに描画
  2. Canvasから全ピクセルのデータ(RGBA値)を取得
  3. アルファ値(透明度)をチェック、「透明と不透明の場所」を記録したヒートマップを作成

2. 実行フェーズ(マウス操作時)

ユーザーがマウスを動かしたりクリックしたりするたびに、保存しておいた地図を参照して判定を行います。

マウス移動時:
  1. カーソルの位置が、画像のどのピクセルに対応するか計算
  2. ヒットマップを参照し、その場所が不透明ならカーソルを「指マーク(pointer)」に、透明なら「矢印(default)」に切り替え
クリック時:
  1. 同様にピクセル位置を特定し、ヒットマップを参照
  2. 不透明ならクリック処理(リンク遷移など)を実行
  3. 透明ならクリックイベントをキャンセルし、何もしない(あるいは背面の要素にイベントを通す)

実装のポイント

何個かポイントを記載していきます
説明しやすいように全体像に記載されているコードから少し変えています

1. ヒートマップの作成とキャッシュ

毎回クリックされるたびにCanvasからデータを取得しては動作が重くなるため、初期化に一度だけ計算してメモリに保存していきます

/**
 * ImageDataからヒットマップを作成
 * @param {Uint8ClampedArray} data - ImageDataの配列
 * @returns {Uint8Array} ヒットマップ
 * @private
 */
createHitmapFromImageData(data) {
  const hitmap = new Uint8Array(this.width * this.height);

  for (let i = 0, j = 3; j < data.length; i++, j += 4) {
    hitmap[i] = data[j] > this.threshold ? 1 : 0;
  }

  return hitmap;
}

/**
 * アルファマップを構築
 * @private
 */
buildHitmap() {
  // 画面には表示しないCanvasを作成
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d", { willReadFrequently: true });
  
  // 画像と同じサイズに設定して描画
  canvas.width = this.width;
  canvas.height = this.height;
  ctx.drawImage(this.img, 0, 0);

  // 全ピクセルの情報を取得 (R, G, B, A, R, G, B, A...) の配列
  const imageData = ctx.getImageData(0, 0, this.width, this.height);
  
  // ヒットマップを作成
  this.hitmap = this.createHitmapFromImageData(imageData.data);
}

メモリ効率

createHitmapFromImageDataメソッドにてRGBAの4つの値のうち、判定に必要なのは「透明かどうか」なので01の情報に圧縮してUint8Arrayに格納、メモリ使用量を削減しています

2. レスポンシブ対応の座標計算

CSS: witdh: 100%などが指定されて画像の表示サイズがもとのサイズと異なっている場合でも正しく判定する必要があります

getPixelCoordinate(event) {
  const rect = this.img.getBoundingClientRect();
  
  // マウスのクライアント座標から、画像の左上位置を引く
  const clientX = event.clientX;
  const clientY = event.clientY;

  // 比率計算: (クリック位置) * (元の幅 / 表示されている幅)
  const x = Math.floor((clientX - rect.left) * (this.width / rect.width));
  const y = Math.floor((clientY - rect.top) * (this.height / rect.height));

  return { x, y };
}

この計算により画像が拡大・縮小されていても正確に元画像のピクセス位置を特定できます

3. パフォーマンス

マイスを動かくたびに発生するpointermoveイベントは非常に頻度が高いため、そのまま思い処理を行うと画面がカクつく原因になります
そこでrequestAnimationFrameを使って処理を間引いています

handleMove(event) {
  this.latestEvent = event;

  // 前回の描画フレームの処理が終わっていなければ何もしない
  if (!this.rafId) {
    this.rafId = requestAnimationFrame(() => {
      this.rafId = null;
      // ... 座標計算とカーソル変更処理 ...
      const isOpaque = this.isOpaqueAt(coordinate.x, coordinate.y);
      this.setCursor(isOpaque ? "pointer" : "default");
    });
  }
}

透明部分ではカーソルがdefault、不透明部分はpointerにスムーズに切り替わります

4. クリックイベント制御

実際のクリック挙動は透明部分だった場合キャンセル、裏にある要素をクリックできるようにしたり誤作動を防いだりします

handleClick(event) {
  const coordinate = this.getPixelCoordinate(event);
  
  if (this.isOpaqueAt(coordinate.x, coordinate.y)) {
    // 不透明ならアクション実行(リンク遷移など)
    this.executeClickAction(event);
  } else {
    // 透明ならクリックを無効化
    this.preventClickPropagation(event);
  }
}

使い方

このクラスは非常に汎用的に作られています。HTML側で data-href 属性を設定するだけでリンクとして機能します

HTML

<img 
  src="character.png" 
  data-href="https://example.com" 
  alt="画像"
>

JavaScript

// 全てのimgタグに適用する例
document.addEventListener("DOMContentLoaded", () => {
  const images = document.querySelectorAll("img");

  images.forEach((img) => {
    setupClickableImage(img, {
      alphaThreshold: 10,
      onClick: (event) => {
        const url = event.target.dataset.href;
        const alt = event.target.alt;

        if (url) {
          alert(`"${alt}" をクリック → ${url}`);
        }
      },
    });
  });
});

注意点:CORS(クロスオリジン)制約

この手法は canvas.getImageData() を使用するため別ドメインの画像を表示している場合、ブラウザのセキュリティ制限(CORS)によりエラーになりますTainted Canvas(汚染されたキャンバス)問題

これを回避するには、画像サーバー側で適切なCORSヘッダー(Access-Control-Allow-Origin)を設定、JavaScript側で画像読み込み時に img.crossOrigin = "Anonymous" を設定する必要があります

リポジトリ

TypeScript版

需要があるかわかりませんがTypeScript版も置いときます

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?