D3.jsとTypeScriptで実現するインタラクティブWebビジュアライゼーション:動的チャート、ヒートマップ、ダッシュボード

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

D3.jsとTypeScriptの組み合わせ——なぜこの組み合わせが機能するのか

2年前、生のCSVエクスポートをプレーンなHTMLテーブルに表示するだけのプロジェクトを引き継ぎました。クライアントは季節的なトレンドの把握、地域パターンの比較、ブラウザ内でのインタラクティブなドリルダウン分析を求めていました。標準的なチャートライブラリは一見十分に見えましたが、カスタムインタラクションが必要になった途端に限界が現れました。そこでD3.jsとTypeScriptの組み合わせに切り替え、以来すべての本番ダッシュボードで同じスタックを使い続けています。

TypeScriptは型安全性を加え、大規模なD3コードベースを保守しやすくします——データ構造の不一致をコンパイル時に検出でき、深夜2時に顧客から壊れたチャートのスクリーンショットが届く事態を避けられます。このスタックを複数の本番プロジェクトで使用してきました。最大のプロジェクトでは、フレームドロップなしに1チャートあたり70,000以上のデータポイントを処理しました。

プロジェクトのセットアップから始め、実際に再利用できる3つのコンポーネントを構築します:アニメーション折れ線グラフ、ヒートマップ、そして統合ダッシュボードです。最後のセクションでは、ユーザーが問題に気づく前にパフォーマンスを検証してリグレッションを検出する方法を解説します。

インストールとプロジェクトのセットアップ

ViteはTypeScriptプロジェクトへの最速の近道です。TypeScriptを自動でコンパイルし、ホットリロード対応の開発サーバーを起動します——webpackの設定と格闘する必要はありません。

npm create vite@latest d3-dashboard -- --template vanilla-ts
cd d3-dashboard
npm install

次にD3とTypeScript型定義をインストールします:

npm install d3
npm install --save-dev @types/d3

@types/d3パッケージはD3 API全体をカバーしています——scaleLinear()の戻り値やaxisBottom()の引数を推測する必要はありません。

tsconfig.jsonを開き、strictモードが有効になっていることを確認します:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

strictモードはいたるところでnullチェックを要求します——コード実行時にDOM要素が存在しない可能性があるD3では特に重要です。

チャートの構築

アニメーション描画付き折れ線グラフ

src/lineChart.tsを作成します。ほぼすべてのチャートで使う3つのD3パターンを示しています:スケール、軸、パスレンダリングです。

import * as d3 from 'd3';

interface DataPoint {
  date: Date;
  value: number;
}

export function drawLineChart(
  selector: string,
  data: DataPoint[]
): void {
  const margin = { top: 20, right: 30, bottom: 40, left: 50 };
  const width = 700 - margin.left - margin.right;
  const height = 400 - margin.top - margin.bottom;

  d3.select(selector).selectAll('*').remove();

  const svg = d3
    .select(selector)
    .append('svg')
    .attr('width', width + margin.left + margin.right)
    .attr('height', height + margin.top + margin.bottom)
    .append('g')
    .attr('transform', `translate(${margin.left},${margin.top})`);

  const x = d3.scaleTime()
    .domain(d3.extent(data, d => d.date) as [Date, Date])
    .range([0, width]);

  const y = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.value) as number])
    .nice()
    .range([height, 0]);

  svg.append('g')
    .attr('transform', `translate(0,${height})`)
    .call(d3.axisBottom(x).ticks(6));

  svg.append('g').call(d3.axisLeft(y));

  const line = d3.line<DataPoint>()
    .x(d => x(d.date))
    .y(d => y(d.value))
    .curve(d3.curveMonotoneX);

  const path = svg.append('path')
    .datum(data)
    .attr('fill', 'none')
    .attr('stroke', '#4f8ef7')
    .attr('stroke-width', 2.5)
    .attr('d', line);

  // stroke-dashoffsetトリックを使った描画アニメーション
  const totalLength = (path.node() as SVGPathElement).getTotalLength();
  path
    .attr('stroke-dasharray', `${totalLength} ${totalLength}`)
    .attr('stroke-dashoffset', totalLength)
    .transition()
    .duration(1200)
    .ease(d3.easeCubicOut)
    .attr('stroke-dashoffset', 0);

  // ツールチップ
  const tooltip = d3.select('body')
    .append('div')
    .style('position', 'absolute')
    .style('background', '#333')
    .style('color', '#fff')
    .style('padding', '6px 10px')
    .style('border-radius', '4px')
    .style('pointer-events', 'none')
    .style('opacity', 0);

  svg.selectAll('circle')
    .data(data)
    .join('circle')
    .attr('cx', d => x(d.date))
    .attr('cy', d => y(d.value))
    .attr('r', 4)
    .attr('fill', '#4f8ef7')
    .on('mouseover', (event, d) => {
      tooltip.transition().duration(150).style('opacity', 1);
      tooltip
        .html(`${d.date.toLocaleDateString()}: <strong>${d.value}</strong>`)
        .style('left', `${event.pageX + 12}px`)
        .style('top', `${event.pageY - 28}px`);
    })
    .on('mouseout', () =>
      tooltip.transition().duration(200).style('opacity', 0)
    );
}

アニメーションはstroke-dasharray/stroke-dashoffsetのSVGトリックで動作します:破線の長さをパスの全長と等しくし、1,200msかけてオフセットをゼロに遷移させます。すると線が自分自身を描画します。path.node() as SVGPathElementのキャストにより、TypeScriptにこれがSVGパスであることを伝え——汎用HTMLElementではなく——getTotalLength()が型エラーなしに解決されます。

パターン発見のためのヒートマップ

ヒートマップは2次元の密度を直感的に読み取れる形式に圧縮します——曜日と時間帯別のサーバー負荷、週次コミット活動、売上パターンなど。2つのカテゴリ軸と数値的な強度を持つデータならどれでもこの形式に合います。src/heatMap.tsを作成します:

import * as d3 from 'd3';

interface HeatCell {
  day: string;
  hour: number;
  value: number;
}

export function drawHeatMap(selector: string, data: HeatCell[]): void {
  const days = ['月', '火', '水', '木', '金', '土', '日'];
  const hours = d3.range(0, 24);
  const cellSize = 28;
  const margin = { top: 40, right: 20, bottom: 20, left: 50 };

  d3.select(selector).selectAll('*').remove();

  const svg = d3
    .select(selector)
    .append('svg')
    .attr('width', hours.length * cellSize + margin.left + margin.right)
    .attr('height', days.length * cellSize + margin.top + margin.bottom)
    .append('g')
    .attr('transform', `translate(${margin.left},${margin.top})`);

  const colorScale = d3
    .scaleSequential()
    .domain([0, d3.max(data, d => d.value) as number])
    .interpolator(d3.interpolateYlOrRd);

  svg.selectAll('.hour-label')
    .data(hours)
    .join('text')
    .attr('x', d => d * cellSize + cellSize / 2)
    .attr('y', -8)
    .attr('text-anchor', 'middle')
    .attr('font-size', 10)
    .text(d => d % 3 === 0 ? `${d}h` : '');

  svg.selectAll('.day-label')
    .data(days)
    .join('text')
    .attr('x', -8)
    .attr('y', (_, i) => i * cellSize + cellSize / 2)
    .attr('dominant-baseline', 'middle')
    .attr('text-anchor', 'end')
    .attr('font-size', 11)
    .text(d => d);

  svg.selectAll('rect')
    .data(data)
    .join('rect')
    .attr('x', d => d.hour * cellSize)
    .attr('y', d => days.indexOf(d.day) * cellSize)
    .attr('width', cellSize - 2)
    .attr('height', cellSize - 2)
    .attr('rx', 3)
    .attr('fill', d => colorScale(d.value))
    .append('title')
    .text(d => `${d.day} ${d.hour}:00 — ${d.value}件`);
}

d3.scaleSequentialinterpolateYlOrRdを組み合わせることで、数値範囲を黄色から赤のグラデーションに自動的にマッピングします。rectに追加した各<title>はネイティブブラウザのツールチップを提供します——位置計算も追加ライブラリも不要です。

ダッシュボードの統合

src/main.tsで、共有データソースを使って両方のチャートを組み合わせます:

import * as d3 from 'd3';
import { drawLineChart } from './lineChart';
import { drawHeatMap } from './heatMap';

async function bootstrap() {
  // APIに接続する際はこれをfetch()呼び出しに置き換えてください
  const lineData = d3
    .timeDays(new Date('2025-01-01'), new Date('2025-07-01'))
    .map(date => ({
      date,
      value: Math.round(
        50 + Math.random() * 80 + Math.sin(date.getMonth()) * 30
      ),
    }));

  const days = ['月', '火', '水', '木', '金', '土', '日'];
  const heatData = days.flatMap(day =>
    d3.range(0, 24).map(hour => ({
      day,
      hour,
      value: Math.round(Math.random() * 100),
    }))
  );

  drawLineChart('#line-chart', lineData);
  drawHeatMap('#heat-map', heatData);
}

bootstrap();

HTML側はシンプルです:

<div id="line-chart"></div>
<div id="heat-map" style="margin-top: 2rem"></div>

実際のAPIの準備ができたら、Math.random()await fetch('/api/metrics').then(r => r.json())に置き換えます。チャート関数はそのまま——変わるのはデータソースだけです。

検証とモニタリング

CIでTypeScriptの正確性を確認するには、コンパイラのno-emitモードを使います——出力ファイルを生成せずに型チェックを実行します:

npx tsc --noEmit

エラーゼロはデータインターフェースがD3の期待値と一致していることを意味します。プルリクエストごとにこれを実行し、APIスキーマの変更が本番環境に到達する前に検出しましょう

レンダリングパフォーマンスをプロファイリングするには、Chrome DevToolsのPerformanceタブを使います。10,000データポイントの読み込み中に記録します。フレームチャートの長い赤いバーは通常、SVG要素が多すぎることを示しています。軸とラベルにSVGを使いながら、ツールチップレイヤーの要素をCanvasに切り替えましょう——D3は同じコンポーネント内で両方のレンダラーをサポートしています。

チャートをレスポンシブにするには、幅をハードコードする代わりに描画時にコンテナ幅を読み取ります:

const containerWidth = (
  d3.select(selector).node() as HTMLElement
).getBoundingClientRect().width;
const width = containerWidth - margin.left - margin.right;

ビューポートが変更されるたびに描画関数を再実行するにはResizeObserverをアタッチします:

const container = document.querySelector(selector) as HTMLElement;
new ResizeObserver(() => drawLineChart(selector, data)).observe(container);

デスクトップとモバイルを一箇所で対応できます。今では新しいダッシュボードを作るたびに迷わずこのパターンを採用しています。

Web Vitalsライブラリでレイアウトの安定性を監視することも重要です。初回描画後のSVGリサイズはCumulative Layout Shift(CLS)を引き起こし、Core Web Vitalsスコアに悪影響を与えます:

npm install web-vitals
import { onCLS, onLCP } from 'web-vitals';
onCLS(metric => console.log('CLS:', metric.value));
onLCP(metric => console.log('LCP:', metric.value));

CLSスコアが0.1を超える場合、SVGのサイズが初回描画前に宣言されていないことを意味します。ルート<svg>に明示的なwidthheight属性を追加するか、コンテナdivをCSSのaspect-ratioルールで囲みましょう。

開発サーバーを起動してダッシュボードを開きます:

npm run dev

折れ線グラフは滑らかな1.2秒のキュービックアニメーションで自身を描画します。ヒートマップは淡い黄色から深い赤へと塗りつぶされます。折れ線グラフのどのドットにもホバーすると——ツールチップが150msで表示され、マウスアウト時にクリーンにフェードアウトします。

次の明確なステップが3つあります:d3.zoom()でズームを追加する、ヒートマップで曜日をクリックすると折れ線グラフがフィルタリングされるようにチャートを連携させる、またはランダムジェネレーターを実際のAPIデータに置き換えることです。今構築した型付きインターフェース、再利用可能な描画関数、スケールベースの座標マッピングは、今後のすべてのD3プロジェクトで活用できるパターンです。

Share: