T-CREATOR

SolidJS で SVG や Canvas を自在に操る

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 との組み合わせで型安全な開発が可能です。専用ライブラリの学習コストが不要な点も大きな魅力です。

柔軟な状態管理: createSignalcreateMemo を使用することで、複雑な図形間の依存関係やデータバインディングを直感的に表現できます。

軽量性: 必要な機能のみをバンドルでき、大きな図形描画ライブラリと比較して軽量なアプリケーションを構築できます。

使い分けのガイドライン

SVG と Canvas の適切な選択は、プロジェクトの要件によって決まります。以下のガイドラインを参考にしてください。

項目SVG が適している場合Canvas が適している場合
要素数少数〜中程度(〜500 要素)大量(1000 要素以上)
インタラクション要素別の細かい操作が必要全体的な操作やゲーム
アニメーションCSS アニメーションで十分複雑な物理演算が必要
アクセシビリティ重要(SEO、スクリーンリーダー対応)優先度が低い
スタイリングCSS での柔軟なスタイリングプログラマティックな描画制御
レスポンシブ自動スケーリングが必要固定サイズでも可

データビジュアライゼーションでは、静的なチャートや少数のデータポイントなら SVG、リアルタイム更新や大量データなら Canvas を選択するのが効果的です。

ゲーム開発では、UI 要素は SVG、ゲーム本体は Canvas という組み合わせも有効です。

インタラクティブなアート作品では、表現力重視なら SVG、パフォーマンス重視なら Canvas を選択します。

SolidJS のリアクティブシステムを活用することで、どちらの技術を選択しても、保守性が高く高性能なアプリケーションを構築できます。状態管理の統一された手法により、SVG と Canvas を組み合わせたハイブリッドアプローチも容易に実現可能です。

今回紹介した技術を活用して、次世代の Web アプリケーションにおける豊かなグラフィック表現を実現してください。

関連リンク