技術 約4分で読めます

SVGで幼児向けウェブ塗り絵を作った話

昔、幼児向けのウェブサイトで塗り絵コンテンツを作る仕事があった。タブレットで遊べる塗り絵で、パスをタップすると色が塗れて、スタンプも押せて、完成したら画像ダウンロードできるやつ。

当時はjQueryで書いたんだけど、仕組み自体はめちゃくちゃシンプルだったので、今ならバニラJSでどう書くかという形で解説する。

基本原理

SVG塗り絵の仕組みは驚くほど単純。

  1. SVGの各図形要素にクラスを振る
  2. クリックされたらそのクラスのfillを変える

これだけ。実質「バケツツール」みたいな動作になる。閉じたパスの中をクリックすると、そのパス全体が塗られる。

なぜシンプルで済むのか

ペイントソフトのバケツツールは、ピクセル単位で「同じ色の隣接領域」を判定して塗りつぶす必要がある。結構重い処理。

一方SVGの塗り絵は、あらかじめ「ここは髪」「ここは服」みたいにパスで分けてある。塗り絵の線画がそのまま領域の境界になっているので、クリックされた要素のfillを変えるだけでいい。

実装

HTML構造

<div class="canvas-container">
  <!-- SVGがここに入る -->
</div>

<div class="palette">
  <button class="color-btn" data-color="#E50012" style="background: #E50012"></button>
  <button class="color-btn" data-color="#F9BE00" style="background: #F9BE00"></button>
  <button class="color-btn" data-color="#6FB92C" style="background: #6FB92C"></button>
  <button class="color-btn" data-color="#00AAD3" style="background: #00AAD3"></button>
  <!-- ... -->
</div>

初期化: SVG図形にクラスを振る

SVGを読み込んだら、塗り対象の図形要素すべてに連番クラスを振る。

function initSvg(container) {
  const shapes = container.querySelectorAll(
    'svg rect, svg circle, svg ellipse, svg polygon, svg path'
  );

  shapes.forEach((shape, index) => {
    shape.classList.add('fillable', `shape-${index}`);
  });
}

fillableは塗り対象の共通クラス、shape-${index}は履歴管理用の識別子。

色塗り: クリックでfill変更

let currentColor = '#E50012';

function setupColorPicker(palette) {
  palette.querySelectorAll('.color-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      currentColor = btn.dataset.color;
    });
  });
}

function setupFill(container, historyManager) {
  container.addEventListener('click', (e) => {
    const shape = e.target.closest('.fillable');
    if (!shape) return;

    const className = [...shape.classList].find(c => c.startsWith('shape-'));
    const prevColor = shape.style.fill || getComputedStyle(shape).fill;

    shape.style.fill = currentColor;

    historyManager.push({
      type: 'fill',
      className,
      prevColor,
      newColor: currentColor
    });
  });
}

e.target.closest('.fillable')で、クリックされた要素が塗り対象かどうかを判定。塗り対象ならstyle.fillを変更する。

Undo機能

履歴を配列で管理して、戻すときは前の色に復元する。

function createHistoryManager() {
  const history = [];

  return {
    push(action) {
      history.push(action);
    },

    undo(container) {
      const action = history.pop();
      if (!action) return;

      switch (action.type) {
        case 'fill': {
          const shape = container.querySelector(`.${action.className}`);
          if (shape) shape.style.fill = action.prevColor;
          break;
        }
        case 'stamp': {
          const stamp = document.getElementById(action.id);
          if (stamp) stamp.remove();
          break;
        }
      }
    }
  };
}

スタンプ機能

スタンプはSVG内に<image>要素を追加する。

let currentStamp = null;

function setupStampPicker(picker) {
  picker.querySelectorAll('.stamp-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      currentStamp = btn.dataset.stamp; // 画像URLなど
    });
  });
}

function setupStamp(container, historyManager) {
  const svg = container.querySelector('svg');

  svg.addEventListener('click', (e) => {
    if (!currentStamp) return;
    if (e.target.closest('.fillable')) return; // 塗り優先

    const rect = svg.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    const id = `stamp-${Date.now()}`;
    const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
    image.setAttribute('id', id);
    image.setAttribute('href', currentStamp);
    image.setAttribute('x', x - 25); // 中心に配置
    image.setAttribute('y', y - 25);
    image.setAttribute('width', 50);
    image.setAttribute('height', 50);

    svg.appendChild(image);

    historyManager.push({
      type: 'stamp',
      id
    });
  });
}

PNG書き出し

SVGをCanvasに描画してPNGとしてダウンロードする。

function downloadAsPng(container) {
  const svg = container.querySelector('svg');
  const svgData = new XMLSerializer().serializeToString(svg);
  const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
  const dataUrl = `data:image/svg+xml;charset=utf-8;base64,${svgBase64}`;

  const canvas = document.createElement('canvas');
  canvas.width = svg.width.baseVal.value;
  canvas.height = svg.height.baseVal.value;
  const ctx = canvas.getContext('2d');

  const img = new Image();
  img.onload = () => {
    ctx.drawImage(img, 0, 0);

    const link = document.createElement('a');
    link.download = 'nurie.png';
    link.href = canvas.toDataURL('image/png');
    link.click();
  };
  img.src = dataUrl;
}

発展: Canvas と組み合わせる

SVGの「パスをクリックして塗る」は、実質バケツツールのような動作。これに加えて「自由に線を描く」機能を足したくなることもある。

その場合、SVGの上に透明なCanvasレイヤーを重ねる構成にすると、両方の機能を共存させられる。

<div class="canvas-container" style="position: relative;">
  <svg><!-- 塗り絵のSVG --></svg>
  <canvas
    style="position: absolute; top: 0; left: 0; pointer-events: none;"
  ></canvas>
</div>
  • SVGレイヤー: パスをクリック → fill変更(バケツ的)
  • Canvasレイヤー: ブラシで自由に描画

ツール切り替え時にpointer-eventsを制御すれば、どちらのレイヤーがクリックを受け取るか切り替えられる。

Canvasでのブラシ描画やフラッドフィル(バケツ塗り)の実装については、簡易ペイントで実際に動くものを作っているので参考にどうぞ。

まとめ

SVG塗り絵の仕組みは「クリックされたパスのfillを変える」だけ。線画があらかじめパスで分割されているので、ピクセル単位の領域判定は不要。

幼児向けコンテンツだったので複雑な機能は要らず、このシンプルな仕組みで十分だった。発展させるならCanvas描画との組み合わせが面白い。