SVGで幼児向けウェブ塗り絵を作った話
昔、幼児向けのウェブサイトで塗り絵コンテンツを作る仕事があった。タブレットで遊べる塗り絵で、パスをタップすると色が塗れて、スタンプも押せて、完成したら画像ダウンロードできるやつ。
当時はjQueryで書いたんだけど、仕組み自体はめちゃくちゃシンプルだったので、今ならバニラJSでどう書くかという形で解説する。
基本原理
SVG塗り絵の仕組みは驚くほど単純。
- SVGの各図形要素にクラスを振る
- クリックされたらそのクラスの
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描画との組み合わせが面白い。