SolidJS で SVG や Canvas を自在に操る

Web アプリケーション開発において、グラフィカルな表現力は今や欠かせない要素となっています。データの可視化、インタラクティブな UI、ゲームライクな体験など、静的なテキストや画像だけでは実現できない豊かな表現が求められています。
その中でも SolidJS は、リアクティブプログラミングの優れた仕組みにより、図形操作において他のフレームワークでは難しい直感的で高性能な開発体験を提供してくれます。特に SVG と Canvas を組み合わせることで、用途に応じた最適なグラフィック表現が可能になります。
本記事では、SolidJS を使って SVG や Canvas を効果的に操る方法について詳しく解説いたします。基礎的な概念から実践的な応用例まで、段階的に学習できる構成でお届けします。
背景
Modern Web における図形描画の重要性
現代の Web アプリケーションでは、データドリブンな意思決定が重要視されており、複雑な情報を分かりやすく伝えるためのデータビジュアライゼーションが必須となっています。
また、ユーザーエクスペリエンス向上のため、静的なインターフェースから動的でインタラクティブなインターフェースへの需要が高まっています。これらの要求に応えるには、従来の HTML/CSS だけでは限界があり、SVG や Canvas といった図形描画技術の活用が不可欠です。
mermaidflowchart TD
modern_web[Modern Web アプリ] --> data_viz[データビジュアライゼーション]
modern_web --> interactive_ui[インタラクティブUI]
modern_web --> gaming[ゲーム要素]
data_viz --> svg_charts[SVG チャート]
data_viz --> canvas_graphs[Canvas グラフ]
interactive_ui --> svg_icons[SVG アイコン]
interactive_ui --> canvas_animation[Canvas アニメーション]
gaming --> canvas_game[Canvas ゲーム]
gaming --> svg_ui[SVG UI要素]
上図のように、Modern Web では様々な場面で図形描画技術が活用されており、適切な技術選択が重要になります。
SolidJS のリアクティブシステムと図形操作の相性
SolidJS のリアクティブシステムは、図形操作において特に優れた性能を発揮します。従来の仮想 DOM を使用するフレームワークと異なり、SolidJS は実際の DOM を直接更新するため、図形の位置やスタイルの変更が高速に反映されます。
リアクティブシグナルにより状態変更を追跡し、必要な部分のみを効率的に更新することで、複雑な図形アニメーションでもスムーズな動作を実現できます。
typescript// SolidJS のリアクティブな図形操作例
import { createSignal, createEffect } from 'solid-js';
function ReactiveCircle() {
const [radius, setRadius] = createSignal(50);
const [position, setPosition] = createSignal({
x: 100,
y: 100,
});
// 状態変更時に自動的に図形が更新される
createEffect(() => {
console.log(
`円の半径が ${radius()}px に変更されました`
);
});
return (
<svg width='300' height='300'>
<circle
cx={position().x}
cy={position().y}
r={radius()}
fill='blue'
onClick={() => setRadius((prev) => prev + 10)}
/>
</svg>
);
}
このサンプルでは、createSignal
で図形の状態を管理し、状態変更時に自動的に DOM が更新される仕組みを示しています。
従来の図形描画ライブラリとの比較
D3.js や Fabric.js などの従来のライブラリと比較して、SolidJS を使用したアプローチには以下のような特徴があります。
項目 | 従来ライブラリ | SolidJS アプローチ |
---|---|---|
学習コスト | 専用 API の習得が必要 | 既存の SolidJS 知識で対応可能 |
バンドルサイズ | 大きめ(50-100KB+) | 軽量(必要な分のみ) |
状態管理 | ライブラリ固有の方法 | SolidJS の統一された状態管理 |
型安全性 | ライブラリ依存 | TypeScript で完全にカバー |
カスタマイズ性 | 制約あり | 完全な制御が可能 |
課題
リアクティブな状態管理と図形描画の同期
図形描画において最も難しい課題の一つは、アプリケーションの状態変更と図形の描画状態を正確に同期させることです。
特に複数の図形要素が相互に影響し合う場合や、外部データソースからの更新を反映する場合に、予期しない描画の不整合が発生する可能性があります。
mermaidstateDiagram-v2
[*] --> データ取得
データ取得 --> 状態更新
状態更新 --> 図形描画
図形描画 --> ユーザー操作
ユーザー操作 --> 状態更新
state 課題点 {
状態更新 --> 描画遅延
描画遅延 --> UI不整合
UI不整合 --> ユーザー混乱
}
上図のように、状態管理と描画の同期問題は、ユーザーエクスペリエンスに直接影響する重要な課題です。
パフォーマンスの最適化問題
大量の図形要素を扱う場合や、高頻度でアニメーションを実行する場合、パフォーマンスのボトルネックが発生しやすくなります。
特に以下のようなシナリオでは注意深い最適化が必要です。
typescript// パフォーマンス問題が起きやすいコード例
function HeavyVisualization() {
const [data, setData] = createSignal(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
x: Math.random() * 800,
y: Math.random() * 600,
color: `hsl(${Math.random() * 360}, 70%, 50%)`,
}))
);
// 全データを毎回レンダリング(非効率)
return (
<svg width='800' height='600'>
<For each={data()}>
{(point) => (
<circle
cx={point.x}
cy={point.y}
r='3'
fill={point.color}
/>
)}
</For>
</svg>
);
}
このようなケースでは、描画対象の絞り込みや、レンダリングの最適化が必要になります。
イベント処理とユーザーインタラクション
図形要素に対するマウスイベントやタッチイベントの処理は、通常の DOM 要素と比較して複雑になりがちです。
特に SVG 要素の座標系や Canvas のピクセル座標の取得、重複する要素のイベント処理などで問題が発生することがあります。
解決策
SolidJS のシグナルを活用した図形状態管理
SolidJS のシグナルシステムを効果的に活用することで、図形の状態管理を簡潔かつ効率的に行えます。
以下は、シグナルを使用した効果的な状態管理の例です。
typescriptimport {
createSignal,
createMemo,
createEffect,
} from 'solid-js';
// 図形状態管理のカスタムフック
function useShapeState(initialShape) {
const [position, setPosition] = createSignal(
initialShape.position
);
const [size, setSize] = createSignal(initialShape.size);
const [color, setColor] = createSignal(
initialShape.color
);
// 計算済みプロパティ
const area = createMemo(() => {
const { width, height } = size();
return width * height;
});
// 境界ボックスの計算
const bounds = createMemo(() => {
const pos = position();
const sz = size();
return {
left: pos.x,
top: pos.y,
right: pos.x + sz.width,
bottom: pos.y + sz.height,
};
});
return {
position,
setPosition,
size,
setSize,
color,
setColor,
area,
bounds,
};
}
このカスタムフックにより、図形の状態管理を再利用可能な形で抽象化できます。
SVG DOM 操作の効率化
SVG 要素の効率的な操作には、SolidJS の細かい更新制御を活用します。
typescriptimport { createSignal, batch, untrack } from 'solid-js';
function OptimizedSVGChart() {
const [chartData, setChartData] = createSignal([]);
const [selectedPoint, setSelectedPoint] =
createSignal(null);
// 複数の状態を一括更新
const updateChart = (newData, newSelection) => {
batch(() => {
setChartData(newData);
setSelectedPoint(newSelection);
});
};
// パフォーマンス重視の描画処理
const renderOptimizedPath = createMemo(() => {
const data = chartData();
if (data.length < 2) return '';
// SVG パスの効率的な生成
const pathCommands = data.map((point, index) => {
const command = index === 0 ? 'M' : 'L';
return `${command} ${point.x} ${point.y}`;
});
return pathCommands.join(' ');
});
return (
<svg viewBox='0 0 400 300' className='chart'>
{/* 最適化されたパス描画 */}
<path
d={renderOptimizedPath()}
stroke='blue'
fill='none'
stroke-width='2'
/>
{/* 選択されたポイントのみハイライト表示 */}
<Show when={selectedPoint()}>
<circle
cx={selectedPoint()?.x}
cy={selectedPoint()?.y}
r='5'
fill='red'
/>
</Show>
</svg>
);
}
batch()
を使用して複数の状態更新を一括処理することで、不必要な再描画を防げます。
Canvas の描画最適化手法
Canvas を使用する場合は、描画コンテキストの効率的な利用が重要です。
typescriptimport {
createSignal,
createEffect,
onCleanup,
onMount,
} from 'solid-js';
function OptimizedCanvas() {
let canvasRef: HTMLCanvasElement | undefined;
let animationId: number;
const [particles, setParticles] = createSignal([]);
const [isAnimating, setIsAnimating] = createSignal(false);
// 効率的な Canvas 描画ループ
const animate = () => {
const canvas = canvasRef;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
// 前フレームのクリア(効率的な方法)
ctx.clearRect(0, 0, canvas.width, canvas.height);
// パーティクルの一括描画
const currentParticles = particles();
if (currentParticles.length > 0) {
ctx.save();
currentParticles.forEach((particle) => {
ctx.beginPath();
ctx.arc(
particle.x,
particle.y,
particle.radius,
0,
Math.PI * 2
);
ctx.fillStyle = particle.color;
ctx.fill();
});
ctx.restore();
}
// 次フレームのスケジュール
if (isAnimating()) {
animationId = requestAnimationFrame(animate);
}
};
// アニメーション開始時の処理
createEffect(() => {
if (isAnimating()) {
animate();
} else {
cancelAnimationFrame(animationId);
}
});
// コンポーネント破棄時のクリーンアップ
onCleanup(() => {
cancelAnimationFrame(animationId);
});
return (
<div className='canvas-container'>
<canvas
ref={canvasRef}
width='800'
height='600'
style={{ border: '1px solid #ccc' }}
/>
<button
onClick={() => setIsAnimating(!isAnimating())}
>
{isAnimating() ? 'Stop' : 'Start'} Animation
</button>
</div>
);
}
requestAnimationFrame
を活用して滑らかなアニメーションを実現し、適切なクリーンアップでメモリリークを防止しています。
具体例
SVG 操作編
基本的な図形描画とアニメーション
SolidJS での SVG 図形描画の基本から、アニメーションまでを段階的に学びましょう。
typescriptimport {
createSignal,
createEffect,
onMount,
} from 'solid-js';
// 基本的な図形コンポーネント
function BasicShapes() {
const [rotation, setRotation] = createSignal(0);
const [scale, setScale] = createSignal(1);
// 継続的な回転アニメーション
onMount(() => {
const interval = setInterval(() => {
setRotation((prev) => (prev + 2) % 360);
}, 16); // 60FPSを目指す
// クリーンアップ
return () => clearInterval(interval);
});
return (
<div className='shapes-demo'>
<svg width='300' height='300' viewBox='0 0 300 300'>
{/* アニメーション付き矩形 */}
<rect
x='50'
y='50'
width='50'
height='50'
fill='blue'
transform={`rotate(${rotation()} 75 75) scale(${scale()})`}
style={{ 'transform-origin': '75px 75px' }}
/>
{/* 連動する円 */}
<circle
cx='200'
cy='100'
r={
20 + Math.sin((rotation() * Math.PI) / 180) * 10
}
fill='red'
opacity={0.7}
/>
{/* インタラクティブな多角形 */}
<polygon
points='150,200 120,250 180,250'
fill='green'
onClick={() => setScale(scale() === 1 ? 1.5 : 1)}
style={{ cursor: 'pointer' }}
transform={`scale(${scale()})`}
/>
</svg>
<div className='controls'>
<label>
スケール: {scale().toFixed(1)}
<input
type='range'
min='0.5'
max='2'
step='0.1'
value={scale()}
onInput={(e) =>
setScale(parseFloat(e.target.value))
}
/>
</label>
</div>
</div>
);
}
このコードでは、回転、スケール、クリックイベントなど、様々なインタラクションを組み合わせた図形操作を実装しています。
インタラクティブな図形操作
ドラッグ&ドロップ機能を持つ図形操作を実装してみましょう。
typescriptimport { createSignal, createEffect } from 'solid-js';
// ドラッグ可能な図形コンポーネント
function DraggableShape() {
const [isDragging, setIsDragging] = createSignal(false);
const [dragOffset, setDragOffset] = createSignal({
x: 0,
y: 0,
});
const [position, setPosition] = createSignal({
x: 150,
y: 150,
});
// マウス座標をSVG座標系に変換
const getSVGCoordinates = (
event: MouseEvent,
svg: SVGSVGElement
) => {
const rect = svg.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
};
// ドラッグ開始処理
const handleMouseDown = (event: MouseEvent) => {
const svg = event.currentTarget.closest(
'svg'
) as SVGSVGElement;
const svgCoords = getSVGCoordinates(event, svg);
const currentPos = position();
setIsDragging(true);
setDragOffset({
x: svgCoords.x - currentPos.x,
y: svgCoords.y - currentPos.y,
});
};
// ドラッグ中の処理
const handleMouseMove = (event: MouseEvent) => {
if (!isDragging()) return;
const svg = event.currentTarget as SVGSVGElement;
const svgCoords = getSVGCoordinates(event, svg);
const offset = dragOffset();
setPosition({
x: svgCoords.x - offset.x,
y: svgCoords.y - offset.y,
});
};
// ドラッグ終了処理
const handleMouseUp = () => {
setIsDragging(false);
};
return (
<svg
width='400'
height='400'
viewBox='0 0 400 400'
style={{
border: '1px solid #ddd',
cursor: isDragging() ? 'grabbing' : 'grab',
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* ドラッグ可能な円 */}
<circle
cx={position().x}
cy={position().y}
r='30'
fill={isDragging() ? '#ff6b6b' : '#4ecdc4'}
stroke='#333'
stroke-width='2'
onMouseDown={handleMouseDown}
style={{
cursor: 'inherit',
transition: isDragging()
? 'none'
: 'fill 0.2s ease',
}}
/>
{/* 座標表示 */}
<text
x='10'
y='30'
fill='#666'
font-size='14'
font-family='monospace'
>
x: {Math.round(position().x)}, y:{' '}
{Math.round(position().y)}
</text>
</svg>
);
}
この実装では、SVG 座標系での正確なドラッグ操作と、視覚的なフィードバックを提供しています。
データビジュアライゼーション
リアルタイムデータを可視化するチャートコンポーネントを作成します。
typescriptimport {
createSignal,
createMemo,
createEffect,
For,
} from 'solid-js';
// データ生成ユーティリティ
const generateData = (points: number) => {
return Array.from({ length: points }, (_, i) => ({
x: i,
y: Math.sin(i * 0.1) * 50 + Math.random() * 20,
timestamp: Date.now() + i * 1000,
}));
};
function RealtimeChart() {
const [data, setData] = createSignal(generateData(50));
const [isLive, setIsLive] = createSignal(false);
const [hoveredPoint, setHoveredPoint] =
createSignal(null);
const chartDimensions = {
width: 600,
height: 300,
padding: { top: 20, right: 20, bottom: 40, left: 40 },
};
// スケール計算
const scales = createMemo(() => {
const currentData = data();
const xExtent = [0, currentData.length - 1];
const yExtent = [
Math.min(...currentData.map((d) => d.y)) - 10,
Math.max(...currentData.map((d) => d.y)) + 10,
];
return {
x: (value: number) =>
(value / (xExtent[1] - xExtent[0])) *
(chartDimensions.width -
chartDimensions.padding.left -
chartDimensions.padding.right) +
chartDimensions.padding.left,
y: (value: number) =>
chartDimensions.height -
chartDimensions.padding.bottom -
((value - yExtent[0]) / (yExtent[1] - yExtent[0])) *
(chartDimensions.height -
chartDimensions.padding.top -
chartDimensions.padding.bottom),
};
});
// パス生成
const pathData = createMemo(() => {
const currentData = data();
const scale = scales();
if (currentData.length < 2) return '';
const pathCommands = currentData.map((point, index) => {
const command = index === 0 ? 'M' : 'L';
return `${command} ${scale.x(point.x)} ${scale.y(
point.y
)}`;
});
return pathCommands.join(' ');
});
// リアルタイムデータ更新
createEffect(() => {
if (!isLive()) return;
const interval = setInterval(() => {
setData((prev) => {
const newData = [
...prev.slice(1),
{
x: prev[prev.length - 1].x + 1,
y:
Math.sin(prev[prev.length - 1].x * 0.1) * 50 +
Math.random() * 20,
timestamp: Date.now(),
},
];
return newData;
});
}, 100);
return () => clearInterval(interval);
});
return (
<div className='chart-container'>
<div className='chart-controls'>
<button onClick={() => setIsLive(!isLive())}>
{isLive() ? 'Stop' : 'Start'} Live Data
</button>
<button onClick={() => setData(generateData(50))}>
Reset Data
</button>
</div>
<svg
width={chartDimensions.width}
height={chartDimensions.height}
style={{ border: '1px solid #ddd' }}
>
{/* グリッド線 */}
<defs>
<pattern
id='grid'
width='50'
height='30'
patternUnits='userSpaceOnUse'
>
<path
d='M 50 0 L 0 0 0 30'
fill='none'
stroke='#f0f0f0'
stroke-width='1'
/>
</pattern>
</defs>
<rect
width='100%'
height='100%'
fill='url(#grid)'
/>
{/* データライン */}
<path
d={pathData()}
stroke='#4ecdc4'
stroke-width='2'
fill='none'
style={{
transition: isLive() ? 'none' : 'all 0.3s ease',
}}
/>
{/* データポイント */}
<For each={data()}>
{(point, index) => (
<circle
cx={scales().x(point.x)}
cy={scales().y(point.y)}
r='4'
fill={
hoveredPoint() === index()
? '#ff6b6b'
: '#4ecdc4'
}
stroke='white'
stroke-width='2'
style={{ cursor: 'pointer' }}
onMouseEnter={() => setHoveredPoint(index())}
onMouseLeave={() => setHoveredPoint(null)}
/>
)}
</For>
{/* 軸ラベル */}
<text
x={chartDimensions.width / 2}
y={chartDimensions.height - 5}
text-anchor='middle'
fill='#666'
font-size='12'
>
Time
</text>
<text
x='15'
y={chartDimensions.height / 2}
text-anchor='middle'
fill='#666'
font-size='12'
transform={`rotate(-90 15 ${
chartDimensions.height / 2
})`}
>
Value
</text>
</svg>
{/* ホバー情報 */}
<Show when={hoveredPoint() !== null}>
<div className='tooltip'>
<p>Point {hoveredPoint()}</p>
<p>
Value: {data()[hoveredPoint()!].y.toFixed(2)}
</p>
</div>
</Show>
</div>
);
}
このチャートは、リアルタイムデータ更新、インタラクティブなホバー効果、スムーズなアニメーションを統合しています。
Canvas 操作編
高速な図形描画
Canvas の高速描画機能を活用したパフォーマンス重視の実装を紹介します。
typescriptimport {
createSignal,
createEffect,
onMount,
onCleanup,
} from 'solid-js';
// パーティクルの型定義
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
color: string;
life: number;
}
function HighPerformanceCanvas() {
let canvasRef: HTMLCanvasElement | undefined;
let ctx: CanvasRenderingContext2D | null = null;
let animationId: number;
const [particleCount, setParticleCount] =
createSignal(500);
const [isRunning, setIsRunning] = createSignal(false);
const [fps, setFps] = createSignal(0);
const particles: Particle[] = [];
let lastTime = 0;
let frameCount = 0;
// パーティクル初期化
const initializeParticles = () => {
particles.length = 0;
const canvas = canvasRef;
if (!canvas) return;
for (let i = 0; i < particleCount(); i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
radius: Math.random() * 3 + 1,
color: `hsl(${Math.random() * 360}, 70%, 60%)`,
life: Math.random() * 100 + 50,
});
}
};
// パーティクル更新ロジック
const updateParticles = (deltaTime: number) => {
const canvas = canvasRef;
if (!canvas) return;
particles.forEach((particle, index) => {
// 位置更新
particle.x += particle.vx * deltaTime * 0.016; // 60FPS基準で正規化
particle.y += particle.vy * deltaTime * 0.016;
// 境界判定と反射
if (
particle.x <= particle.radius ||
particle.x >= canvas.width - particle.radius
) {
particle.vx *= -0.8; // エネルギー減衰
particle.x = Math.max(
particle.radius,
Math.min(
canvas.width - particle.radius,
particle.x
)
);
}
if (
particle.y <= particle.radius ||
particle.y >= canvas.height - particle.radius
) {
particle.vy *= -0.8;
particle.y = Math.max(
particle.radius,
Math.min(
canvas.height - particle.radius,
particle.y
)
);
}
// 寿命減少
particle.life -= deltaTime * 0.016;
// 寿命切れのパーティクルを再生成
if (particle.life <= 0) {
Object.assign(particle, {
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
life: Math.random() * 100 + 50,
});
}
});
};
// 高速描画処理
const render = () => {
if (!ctx || !canvasRef) return;
const canvas = canvasRef;
// 背景クリア(最適化)
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; // トレイル効果
ctx.fillRect(0, 0, canvas.width, canvas.height);
// パーティクルの一括描画
ctx.globalCompositeOperation = 'lighter'; // 加算合成で発光効果
particles.forEach((particle) => {
const alpha = particle.life / 100;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(
particle.x,
particle.y,
particle.radius,
0,
Math.PI * 2
);
ctx.fill();
ctx.restore();
});
};
// メインアニメーションループ
const animate = (currentTime: number) => {
if (!isRunning()) {
animationId = requestAnimationFrame(animate);
return;
}
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
// FPS計算
frameCount++;
if (frameCount % 60 === 0) {
setFps(Math.round(1000 / (deltaTime || 16)));
}
updateParticles(deltaTime);
render();
animationId = requestAnimationFrame(animate);
};
// Canvas初期化
onMount(() => {
if (canvasRef) {
ctx = canvasRef.getContext('2d');
if (ctx) {
// Canvas設定の最適化
ctx.imageSmoothingEnabled = false; // ピクセル描画の高速化
canvasRef.width = 800;
canvasRef.height = 600;
}
initializeParticles();
animationId = requestAnimationFrame(animate);
}
});
// パーティクル数変更時の処理
createEffect(() => {
const count = particleCount();
if (particles.length !== count) {
initializeParticles();
}
});
// クリーンアップ
onCleanup(() => {
cancelAnimationFrame(animationId);
});
return (
<div className='canvas-demo'>
<div className='controls'>
<button onClick={() => setIsRunning(!isRunning())}>
{isRunning() ? 'Pause' : 'Start'}
</button>
<label>
Particles: {particleCount()}
<input
type='range'
min='100'
max='2000'
step='100'
value={particleCount()}
onInput={(e) =>
setParticleCount(parseInt(e.target.value))
}
/>
</label>
<span className='fps'>FPS: {fps()}</span>
</div>
<canvas
ref={canvasRef}
style={{
border: '1px solid #333',
background: 'black',
display: 'block',
}}
/>
</div>
);
}
このコードは、最適化されたレンダリング、効率的な物理計算、FPS 監視機能を備えた高性能な Canvas 描画システムです。
ゲームライクなアニメーション
インタラクティブなゲームライクなアニメーションの実装例を紹介します。
typescriptimport {
createSignal,
createEffect,
onMount,
onCleanup,
} from 'solid-js';
// ゲームオブジェクトの型定義
interface GameObject {
x: number;
y: number;
width: number;
height: number;
vx: number;
vy: number;
color: string;
}
interface Enemy extends GameObject {
health: number;
type: 'fast' | 'strong' | 'normal';
}
function GameCanvas() {
let canvasRef: HTMLCanvasElement | undefined;
let ctx: CanvasRenderingContext2D | null = null;
let animationId: number;
const [gameState, setGameState] = createSignal<
'playing' | 'paused' | 'gameOver'
>('paused');
const [score, setScore] = createSignal(0);
const [lives, setLives] = createSignal(3);
// ゲームオブジェクト
const player: GameObject = {
x: 400,
y: 500,
width: 40,
height: 40,
vx: 0,
vy: 0,
color: '#4ecdc4',
};
const bullets: GameObject[] = [];
const enemies: Enemy[] = [];
const explosions: Array<{
x: number;
y: number;
frame: number;
}> = [];
// キー入力状態
const keys: Record<string, boolean> = {};
// キーボードイベント処理
const handleKeyDown = (e: KeyboardEvent) => {
keys[e.key] = true;
// スペースキーで弾発射
if (e.key === ' ' && gameState() === 'playing') {
e.preventDefault();
bullets.push({
x: player.x + player.width / 2 - 2,
y: player.y,
width: 4,
height: 10,
vx: 0,
vy: -8,
color: '#ffeb3b',
});
}
};
const handleKeyUp = (e: KeyboardEvent) => {
keys[e.key] = false;
};
// プレイヤー操作
const updatePlayer = () => {
if (gameState() !== 'playing') return;
// 左右移動
if (keys['ArrowLeft']) player.vx = -5;
else if (keys['ArrowRight']) player.vx = 5;
else player.vx *= 0.8; // 慣性減衰
// 位置更新
player.x += player.vx;
// 画面境界チェック
const canvas = canvasRef;
if (canvas) {
player.x = Math.max(
0,
Math.min(canvas.width - player.width, player.x)
);
}
};
// 弾の更新
const updateBullets = () => {
for (let i = bullets.length - 1; i >= 0; i--) {
const bullet = bullets[i];
bullet.y += bullet.vy;
// 画面外の弾を削除
if (bullet.y < -bullet.height) {
bullets.splice(i, 1);
}
}
};
// 敵の生成
const spawnEnemy = () => {
const canvas = canvasRef;
if (!canvas || Math.random() > 0.02) return;
const types: Array<Enemy['type']> = [
'fast',
'strong',
'normal',
];
const type =
types[Math.floor(Math.random() * types.length)];
const enemyConfig = {
fast: {
width: 25,
height: 25,
health: 1,
speed: 3,
color: '#ff6b6b',
},
strong: {
width: 50,
height: 50,
health: 3,
speed: 1,
color: '#845ec2',
},
normal: {
width: 35,
height: 35,
health: 2,
speed: 2,
color: '#ffa726',
},
}[type];
enemies.push({
x: Math.random() * (canvas.width - enemyConfig.width),
y: -enemyConfig.height,
width: enemyConfig.width,
height: enemyConfig.height,
vx: (Math.random() - 0.5) * 2,
vy: enemyConfig.speed,
color: enemyConfig.color,
health: enemyConfig.health,
type: type,
});
};
// 敵の更新
const updateEnemies = () => {
for (let i = enemies.length - 1; i >= 0; i--) {
const enemy = enemies[i];
enemy.x += enemy.vx;
enemy.y += enemy.vy;
// 画面外チェック
const canvas = canvasRef;
if (!canvas) continue;
if (enemy.y > canvas.height) {
enemies.splice(i, 1);
continue;
}
// 左右の境界で反射
if (
enemy.x <= 0 ||
enemy.x + enemy.width >= canvas.width
) {
enemy.vx *= -1;
}
}
};
// 衝突判定
const checkCollisions = () => {
// 弾と敵の衝突
for (let i = bullets.length - 1; i >= 0; i--) {
const bullet = bullets[i];
for (let j = enemies.length - 1; j >= 0; j--) {
const enemy = enemies[j];
if (
bullet.x < enemy.x + enemy.width &&
bullet.x + bullet.width > enemy.x &&
bullet.y < enemy.y + enemy.height &&
bullet.y + bullet.height > enemy.y
) {
// 衝突処理
bullets.splice(i, 1);
enemy.health--;
if (enemy.health <= 0) {
// 敵撃破
explosions.push({
x: enemy.x + enemy.width / 2,
y: enemy.y + enemy.height / 2,
frame: 0,
});
enemies.splice(j, 1);
setScore((prev) => prev + 10);
}
break;
}
}
}
// プレイヤーと敵の衝突
for (let i = enemies.length - 1; i >= 0; i--) {
const enemy = enemies[i];
if (
player.x < enemy.x + enemy.width &&
player.x + player.width > enemy.x &&
player.y < enemy.y + enemy.height &&
player.y + player.height > enemy.y
) {
// ダメージ処理
explosions.push({
x: player.x + player.width / 2,
y: player.y + player.height / 2,
frame: 0,
});
enemies.splice(i, 1);
setLives((prev) => {
const newLives = prev - 1;
if (newLives <= 0) {
setGameState('gameOver');
}
return newLives;
});
}
}
};
// 爆発エフェクトの更新
const updateExplosions = () => {
for (let i = explosions.length - 1; i >= 0; i--) {
explosions[i].frame++;
if (explosions[i].frame > 20) {
explosions.splice(i, 1);
}
}
};
// 描画処理
const render = () => {
if (!ctx || !canvasRef) return;
const canvas = canvasRef;
// 背景クリア
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// プレイヤー描画
ctx.fillStyle = player.color;
ctx.fillRect(
player.x,
player.y,
player.width,
player.height
);
// 弾描画
bullets.forEach((bullet) => {
ctx.fillStyle = bullet.color;
ctx.fillRect(
bullet.x,
bullet.y,
bullet.width,
bullet.height
);
});
// 敵描画
enemies.forEach((enemy) => {
ctx.fillStyle = enemy.color;
ctx.fillRect(
enemy.x,
enemy.y,
enemy.width,
enemy.height
);
// 体力バー表示
if (enemy.health > 1) {
ctx.fillStyle = '#333';
ctx.fillRect(enemy.x, enemy.y - 8, enemy.width, 4);
ctx.fillStyle = '#4caf50';
ctx.fillRect(
enemy.x,
enemy.y - 8,
(enemy.width * enemy.health) / 3,
4
);
}
});
// 爆発エフェクト描画
explosions.forEach((explosion) => {
const progress = explosion.frame / 20;
const radius = 30 * progress;
const alpha = 1 - progress;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = '#ffeb3b';
ctx.beginPath();
ctx.arc(
explosion.x,
explosion.y,
radius,
0,
Math.PI * 2
);
ctx.fill();
ctx.restore();
});
// UI描画
ctx.fillStyle = '#fff';
ctx.font = '16px monospace';
ctx.fillText(`Score: ${score()}`, 10, 30);
ctx.fillText(`Lives: ${lives()}`, 10, 50);
if (gameState() === 'paused') {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '24px monospace';
ctx.textAlign = 'center';
ctx.fillText(
'Press SPACE to Start',
canvas.width / 2,
canvas.height / 2
);
ctx.textAlign = 'start';
}
if (gameState() === 'gameOver') {
ctx.fillStyle = 'rgba(255, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '36px monospace';
ctx.textAlign = 'center';
ctx.fillText(
'GAME OVER',
canvas.width / 2,
canvas.height / 2 - 20
);
ctx.font = '18px monospace';
ctx.fillText(
`Final Score: ${score()}`,
canvas.width / 2,
canvas.height / 2 + 20
);
ctx.textAlign = 'start';
}
};
// メインゲームループ
const gameLoop = () => {
if (gameState() === 'playing') {
updatePlayer();
updateBullets();
spawnEnemy();
updateEnemies();
checkCollisions();
updateExplosions();
}
render();
animationId = requestAnimationFrame(gameLoop);
};
// ゲーム開始/一時停止
const toggleGame = () => {
if (gameState() === 'paused') {
setGameState('playing');
} else if (gameState() === 'playing') {
setGameState('paused');
} else {
// ゲームリセット
setGameState('paused');
setScore(0);
setLives(3);
player.x = 400;
player.y = 500;
bullets.length = 0;
enemies.length = 0;
explosions.length = 0;
}
};
// 初期化
onMount(() => {
if (canvasRef) {
ctx = canvasRef.getContext('2d');
canvasRef.width = 800;
canvasRef.height = 600;
// イベントリスナー追加
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
gameLoop();
}
});
// スペースキーでゲーム開始
createEffect(() => {
const handleSpaceKey = (e: KeyboardEvent) => {
if (e.key === ' ' && gameState() !== 'playing') {
e.preventDefault();
toggleGame();
}
};
document.addEventListener('keydown', handleSpaceKey);
return () =>
document.removeEventListener(
'keydown',
handleSpaceKey
);
});
// クリーンアップ
onCleanup(() => {
cancelAnimationFrame(animationId);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
});
return (
<div className='game-container'>
<div className='game-info'>
<h3>Space Shooter Game</h3>
<p>Arrow keys: Move, Space: Shoot/Start</p>
<button onClick={toggleGame}>
{gameState() === 'playing'
? 'Pause'
: gameState() === 'paused'
? 'Resume'
: 'New Game'}
</button>
</div>
<canvas
ref={canvasRef}
style={{
border: '2px solid #333',
background: 'black',
display: 'block',
}}
/>
</div>
);
}
このゲーム実装では、プレイヤー操作、敵の AI、衝突判定、エフェクト描画、ゲーム状態管理など、本格的なゲーム開発で必要となる要素を統合しています。
リアルタイムグラフィック処理
リアルタイムで画像処理を行う Canvas アプリケーションの実装例です。
typescriptimport { createSignal, createEffect, onMount, onCleanup } from 'solid-js';
function RealtimeImageProcessor() {
let canvasRef: HTMLCanvasElement | undefined;
let videoRef: HTMLVideoElement | undefined;
let ctx: CanvasRenderingContext2D | null = null;
let animationId: number;
const [isProcessing, setIsProcessing] = createSignal(false);
const [filterType, setFilterType] = createSignal<'none' | 'grayscale' | 'sepia' | 'invert' | 'blur' | 'edge'>('none');
const [intensity, setIntensity] = createSignal(1.0);
// Webカメラの初期化
const initializeCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
if (videoRef) {
videoRef.srcObject = stream;
videoRef.play();
}
} catch (error) {
console.error('Camera access denied:', error);
}
};
// 画像フィルター処理
const applyFilter = (imageData: ImageData) => {
const data = imageData.data;
const filter = filterType();
const level = intensity();
switch (filter) {
case 'grayscale':
for (let i = 0; i < data.length; i += 4) {
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
data[i] = data[i] * (1 - level) + gray * level; // R
data[i + 1] = data[i + 1] * (1 - level) + gray * level; // G
data[i + 2] = data[i + 2] * (1 - level) + gray * level; // B
}
break;
case 'sepia':
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const sepiaR = Math.min(255, (r * 0.393 + g * 0.769 + b * 0.189) * level + r * (1 - level));
const sepiaG = Math.min(255, (r * 0.349 + g * 0.686 + b * 0.168) * level + g * (1 - level));
const sepiaB = Math.min(255, (r * 0.272 + g * 0.534 + b * 0.131) * level + b * (1 - level));
data[i] = sepiaR;
data[i + 1] = sepiaG;
data[i + 2] = sepiaB;
}
break;
case 'invert':
for (let i = 0; i < data.length; i += 4) {
data[i] = data[i] * (1 - level) + (255 - data[i]) * level;
data[i + 1] = data[i + 1] * (1 - level) + (255 - data[i + 1]) * level;
data[i + 2] = data[i + 2] * (1 - level) + (255 - data[i + 2]) * level;
}
break;
case 'edge':
applyEdgeDetection(imageData, level);
break;
}
return imageData;
};
// エッジ検出フィルター(Sobelオペレーター)
const applyEdgeDetection = (imageData: ImageData, intensity: number) => {
const { data, width, height } = imageData;
const output = new Uint8ClampedArray(data);
// Sobelカーネル
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let gx = 0, gy = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const idx = ((y + ky) * width + (x + kx)) * 4;
const gray = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114;
const kernelIdx = (ky + 1) * 3 + (kx + 1);
gx += gray * sobelX[kernelIdx];
gy += gray * sobelY[kernelIdx];
}
}
const magnitude = Math.sqrt(gx * gx + gy * gy);
const outputIdx = (y * width + x) * 4;
const originalR = data[outputIdx];
const originalG = data[outputIdx + 1];
const originalB = data[outputIdx + 2];
output[outputIdx] = originalR * (1 - intensity) + magnitude * intensity;
output[outputIdx + 1] = originalG * (1 - intensity) + magnitude * intensity;
output[outputIdx + 2] = originalB * (1 - intensity) + magnitude * intensity;
}
}
data.set(output);
};
// ぼかしフィルター(Gaussian Blur近似)
const applyBlur = (imageData: ImageData, radius: number) => {
const { data, width, height } = imageData;
const output = new Uint8ClampedArray(data);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, count = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const idx = (ny * width + nx) * 4;
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
count++;
}
}
}
const outputIdx = (y * width + x) * 4;
output[outputIdx] = r / count;
output[outputIdx + 1] = g / count;
output[outputIdx + 2] = b / count;
}
}
data.set(output);
};
// メイン処理ループ
const processFrame = () => {
if (!ctx || !canvasRef || !videoRef || !isProcessing()) {
animationId = requestAnimationFrame(processFrame);
return;
}
const canvas = canvasRef;
const video = videoRef;
// ビデオからフレームを取得
if (video.readyState >= 2) {
// ビデオをCanvasに描画
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 画像データを取得
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// フィルター適用
if (filterType() === 'blur') {
applyBlur(imageData, Math.floor(intensity() * 5));
} else {
applyFilter(imageData);
}
// 処理済み画像をCanvasに描画
ctx.putImageData(imageData, 0, 0);
}
animationId = requestAnimationFrame(processFrame);
};
// 初期化
onMount(() => {
if (canvasRef) {
ctx = canvasRef.getContext('2d');
canvasRef.width = 640;
canvasRef.height = 480;
initializeCamera();
processFrame();
}
});
// 処理開始/停止の制御
createEffect(() => {
// フィルターやインテンシティが変更されたときは自動的に次フレームで反映される
});
// クリーンアップ
onCleanup(() => {
cancelAnimationFrame(animationId);
if (videoRef && videoRef.srcObject) {
const stream = videoRef.srcObject as MediaStream;
stream.getTracks().forEach(track => track.stop());
}
});
return (
<div className="image-processor">
<div className="controls">
<h3>Real-time Image Processing</h3>
<button onClick={() => setIsProcessing(!isProcessing())}>
{isProcessing() ? 'Stop Processing' : 'Start Processing'}
</button>
<div className="filter-controls">
<label>
Filter Type:
<select value={filterType()} onChange={(e) => setFilterType(e.target.value as any)}>
<option value="none">None</option>
<option value="grayscale">Grayscale</option>
<option value="sepia">Sepia</option>
<option value="invert">Invert</option>
<option value="blur">Blur</option>
<option value="edge">Edge Detection</option>
</select>
</label>
<label>
Intensity: {intensity().toFixed(1)}
<input
type="range"
min="0"
max="1"
step="0.1"
value={intensity()}
onInput={(e) => setIntensity(parseFloat(e.target.value))}
/>
</label>
</div>
</div>
<div className="video-container">
<video
ref={videoRef}
style={{ display: 'none' }}
autoplay
muted
playsInline
/>
<canvas
ref={canvasRef}
style={{
border: '2px solid #333',
max-width: '100%',
height: 'auto'
}}
/>
</div>
<div className="performance-info">
<p>
使用中のフィルター: <strong>{filterType()}</strong><br/>
強度: <strong>{Math.round(intensity() * 100)}%</strong>
</p>
</div>
</div>
);
}
この実装では、Web カメラからのリアルタイム映像に対して、グレースケール、セピア、反転、ブラー、エッジ検出などの画像処理フィルターを適用できます。
mermaidflowchart LR
webcam[Webカメラ] -->|映像取得| video[Video要素]
video -->|フレーム描画| canvas[Canvas]
canvas -->|画像データ取得| imagedata[ImageData]
imagedata -->|フィルター適用| processor[画像処理]
processor -->|結果描画| canvas
controls[UI制御] --> processor
controls --> video
上図のように、リアルタイム画像処理では映像取得から描画まで効率的なパイプラインの構築が重要になります。
まとめ
SolidJS での図形操作のメリット
SolidJS を使用した SVG・Canvas 操作には、以下のような大きなメリットがあります。
パフォーマンスの優位性: 仮想 DOM を使用せず、シグナルベースのリアクティブシステムにより、図形の状態変更が直接 DOM に反映されるため、アニメーションやインタラクションが非常にスムーズです。
開発体験の向上: 既存の SolidJS の知識をそのまま活用でき、TypeScript との組み合わせで型安全な開発が可能です。専用ライブラリの学習コストが不要な点も大きな魅力です。
柔軟な状態管理: createSignal
や createMemo
を使用することで、複雑な図形間の依存関係やデータバインディングを直感的に表現できます。
軽量性: 必要な機能のみをバンドルでき、大きな図形描画ライブラリと比較して軽量なアプリケーションを構築できます。
使い分けのガイドライン
SVG と Canvas の適切な選択は、プロジェクトの要件によって決まります。以下のガイドラインを参考にしてください。
項目 | SVG が適している場合 | Canvas が適している場合 |
---|---|---|
要素数 | 少数〜中程度(〜500 要素) | 大量(1000 要素以上) |
インタラクション | 要素別の細かい操作が必要 | 全体的な操作やゲーム |
アニメーション | CSS アニメーションで十分 | 複雑な物理演算が必要 |
アクセシビリティ | 重要(SEO、スクリーンリーダー対応) | 優先度が低い |
スタイリング | CSS での柔軟なスタイリング | プログラマティックな描画制御 |
レスポンシブ | 自動スケーリングが必要 | 固定サイズでも可 |
データビジュアライゼーションでは、静的なチャートや少数のデータポイントなら SVG、リアルタイム更新や大量データなら Canvas を選択するのが効果的です。
ゲーム開発では、UI 要素は SVG、ゲーム本体は Canvas という組み合わせも有効です。
インタラクティブなアート作品では、表現力重視なら SVG、パフォーマンス重視なら Canvas を選択します。
SolidJS のリアクティブシステムを活用することで、どちらの技術を選択しても、保守性が高く高性能なアプリケーションを構築できます。状態管理の統一された手法により、SVG と Canvas を組み合わせたハイブリッドアプローチも容易に実現可能です。
今回紹介した技術を活用して、次世代の Web アプリケーションにおける豊かなグラフィック表現を実現してください。
関連リンク
- article
SolidJS で SVG や Canvas を自在に操る
- article
SolidJS アドオン&エコシステム最新事情
- article
SolidJS のカスタムフック(create*系)活用事例集
- article
SolidJS で多言語化(i18n)対応を行う
- article
SolidJS のパフォーマンス計測&プロファイリング
- article
SolidJS でグローバルスタイルを管理する方法
- article
MySQL 基本操作徹底解説:SELECT/INSERT/UPDATE/DELETE の正しい書き方
- article
2025年 Dify コミュニティとエコシステムの最新動向
- article
Motion(旧 Framer Motion)Gesture アニメーション実践:whileHover/whileTap/whileFocus の設計術
- article
Cursor で作る AI 駆動型ドキュメント生成ワークフロー
- article
Cline で Git 操作を自動化する方法
- article
【早見表】JavaScript でよく使う Math メソッドの一覧と活用事例
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来