都道府県別コロプレス地図の作成方法

D3.jsNext.js

コロプレス地図(choropleth map)は、地理的な領域をデータの値に応じて色分けした地図です。この記事では、Next.jsとD3.jsを組み合わせて、インタラクティブな日本地図コンポーネントを実装する方法を解説します。

コンポーネントの概要

このJapanMapコンポーネントは以下の特徴を持っています:

  • Next.jsのクライアントコンポーネントとして実装
  • D3.jsを使用した地図の描画とインタラクション
  • TopoJSONデータを使用した日本地図の表示
  • 都道府県別データに基づく色分け表示
  • ダークモード対応
  • ホバー時のツールチップ表示
  • カラースキームの切り替え機能
注意

これは警告メッセージです。重要な注意事項を書きます。

コード全体を表示
'use client';

import { useRef, useEffect, useState } from 'react';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import { Topology, GeometryObject, GeometryCollection } from 'topojson-specification';
import { Feature, FeatureCollection, Geometry, GeoJsonProperties } from 'geojson';
import { GeoPath, GeoPermissibleObjects } from 'd3-geo';
import { useTheme } from 'next-themes';

// 都道府県コードと名前のマッピング
const PREFECTURE_MAP: Record<string, string> = {
  "01": "北海道", "02": "青森県", "03": "岩手県", "04": "宮城県", "05": "秋田県",
  "06": "山形県", "07": "福島県", "08": "茨城県", "09": "栃木県", "10": "群馬県",
  "11": "埼玉県", "12": "千葉県", "13": "東京都", "14": "神奈川県", "15": "新潟県",
  "16": "富山県", "17": "石川県", "18": "福井県", "19": "山梨県", "20": "長野県",
  "21": "岐阜県", "22": "静岡県", "23": "愛知県", "24": "三重県", "25": "滋賀県",
  "26": "京都府", "27": "大阪府", "28": "兵庫県", "29": "奈良県", "30": "和歌山県",
  "31": "鳥取県", "32": "島根県", "33": "岡山県", "34": "広島県", "35": "山口県",
  "36": "徳島県", "37": "香川県", "38": "愛媛県", "39": "高知県", "40": "福岡県",
  "41": "佐賀県", "42": "長崎県", "43": "熊本県", "44": "大分県", "45": "宮崎県",
  "46": "鹿児島県", "47": "沖縄県"
};

// 都道府県名と都道府県コードのマッピング(逆引き用)
const PREFECTURE_NAME_TO_CODE: Record<string, string> = Object.entries(PREFECTURE_MAP)
  .reduce((acc, [code, name]) => ({ ...acc, [name]: code }), {});

// タイトル、データ、単位を外部で定義
const MAP_TITLE = "都道府県別人口(2023年)";
const MAP_UNIT = "万人";

// 全都道府県のデータ
const PREFECTURE_DATA = [
  { prefName: "北海道", value: 520 },
  { prefName: "青森県", value: 124 },
  { prefName: "岩手県", value: 121 },
  { prefName: "宮城県", value: 230 },
  { prefName: "秋田県", value: 96 },
  { prefName: "山形県", value: 107 },
  { prefName: "福島県", value: 184 },
  { prefName: "茨城県", value: 287 },
  { prefName: "栃木県", value: 194 },
  { prefName: "群馬県", value: 195 },
  { prefName: "埼玉県", value: 730 },
  { prefName: "千葉県", value: 624 },
  { prefName: "東京都", value: 1400 },
  { prefName: "神奈川県", value: 920 },
  { prefName: "新潟県", value: 223 },
  { prefName: "富山県", value: 105 },
  { prefName: "石川県", value: 114 },
  { prefName: "福井県", value: 77 },
  { prefName: "山梨県", value: 81 },
  { prefName: "長野県", value: 206 },
  { prefName: "岐阜県", value: 199 },
  { prefName: "静岡県", value: 364 },
  { prefName: "愛知県", value: 750 },
  { prefName: "三重県", value: 179 },
  { prefName: "滋賀県", value: 141 },
  { prefName: "京都府", value: 259 },
  { prefName: "大阪府", value: 880 },
  { prefName: "兵庫県", value: 550 },
  { prefName: "奈良県", value: 135 },
  { prefName: "和歌山県", value: 93 },
  { prefName: "鳥取県", value: 56 },
  { prefName: "島根県", value: 67 },
  { prefName: "岡山県", value: 190 },
  { prefName: "広島県", value: 282 },
  { prefName: "山口県", value: 137 },
  { prefName: "徳島県", value: 73 },
  { prefName: "香川県", value: 97 },
  { prefName: "愛媛県", value: 135 },
  { prefName: "高知県", value: 70 },
  { prefName: "福岡県", value: 510 },
  { prefName: "佐賀県", value: 82 },
  { prefName: "長崎県", value: 134 },
  { prefName: "熊本県", value: 175 },
  { prefName: "大分県", value: 114 },
  { prefName: "宮崎県", value: 108 },
  { prefName: "鹿児島県", value: 160 },
  { prefName: "沖縄県", value: 146 }
];

// TopoJSONの型定義
interface JapanTopoJSON extends Topology {
  objects: {
    [key: string]: GeometryObject | GeometryCollection;
  };
}

interface JapanMapProps {
  colorScheme?: 'blue' | 'green' | 'red' | 'purple' | 'orange';
}

export default function PrefectureRankTotalPopulationMap({
  colorScheme = 'blue'
}: JapanMapProps) {
  const svgRef = useRef<SVGSVGElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const { resolvedTheme } = useTheme();
  const isDarkMode = resolvedTheme === 'dark';
  const [topoData, setTopoData] = useState<JapanTopoJSON | null>(null);

  // カラースキームの設定
  const getColorInterpolator = (scheme: string, isDark: boolean) => {
    switch (scheme) {
      case 'green': return isDark ? d3.interpolateGnBu : d3.interpolateGreens;
      case 'red': return isDark ? d3.interpolateRdPu : d3.interpolateReds;
      case 'purple': return isDark ? d3.interpolatePuRd : d3.interpolatePurples;
      case 'orange': return isDark ? d3.interpolateOrRd : d3.interpolateOranges;
      case 'blue':
      default: return isDark ? d3.interpolateBlues : d3.interpolateBlues;
    }
  };

  // TopoJSONデータの読み込み
  useEffect(() => {
    fetch('/data/japan-topojson.json')
      .then(response => response.json())
      .then(data => {
        setTopoData(data);
      })
      .catch(error => {
        console.error('地図データの読み込みに失敗しました:', error);
      });
  }, []);

  // 地図の描画
  useEffect(() => {
    if (!svgRef.current || !topoData) return;

    try {
      const svg = d3.select(svgRef.current);
      const container = containerRef.current;
      if (!container) return;

      // コンテナのサイズを取得
      const width = container.clientWidth;
      const height = width * 0.8; // アスペクト比を設定

      // SVGのサイズを設定
      svg.attr('viewBox', `0 0 ${width} ${height}`);
      svg.selectAll("*").remove();

      // 地図の投影法
      const projection = d3.geoMercator()
        .center([137, 38]) // 日本の中心あたり
        .scale(width * 1.2)
        .translate([width / 2, height / 2]);

      // パスジェネレータ
      const path = d3.geoPath().projection(projection);

      // TopoJSONをGeoJSONに変換
      const japan = feature(
        topoData,
        topoData.objects.japan as GeometryObject
      ) as FeatureCollection;

      // データの結合
      const prefectureData = new Map<string, number>();
      
      // データを都道府県コードとマッピング
      PREFECTURE_DATA.forEach(d => {
        const prefCode = PREFECTURE_NAME_TO_CODE[d.prefName];
        if (prefCode) {
          prefectureData.set(prefCode, d.value);
        }
      });

      // カラースケール
      const colorInterpolator = getColorInterpolator(colorScheme, isDarkMode);
      const colorScale = d3.scaleSequential()
        .domain([0, d3.max(Array.from(prefectureData.values())) || 0])
        .interpolator(colorInterpolator);

      // ツールチップの設定
      const tooltip = d3.select(tooltipRef.current);

      // 地図の描画
      svg.selectAll('path')
        .data(japan.features)
        .join('path')
        .attr('d', path as GeoPath<any, GeoPermissibleObjects>)
        .attr('fill', (d, i) => {
          const index = i;
          const prefCode = String(index + 1).padStart(2, '0');
          return prefectureData.has(prefCode) 
            ? colorScale(prefectureData.get(prefCode) || 0) 
            : '#ccc';
        })
        .attr('stroke', isDarkMode ? '#555' : 'white')
        .attr('stroke-width', 0.5)
        .attr('class', 'prefecture')
        .on('mouseover', function(event, d) {
          // 型アサーションを使用
          const feature = d as Feature<Geometry, GeoJsonProperties>;
          const index = japan.features.indexOf(feature);
          const prefCode = String(index + 1).padStart(2, '0');
          const prefName = PREFECTURE_MAP[prefCode] || '不明';
          
          // 要素をハイライト
          d3.select(this)
            .attr('stroke', '#333')
            .attr('stroke-width', 2);
          
          // 値を取得
          let value = '該当データなし';
          
          if (prefectureData.has(prefCode)) {
            value = (prefectureData.get(prefCode) || 0) + MAP_UNIT;
          }
          
          // コンテナの位置を取得
          const containerRect = containerRef.current?.getBoundingClientRect();
          if (containerRect) {
            const mouseX = event.clientX - containerRect.left;
            const mouseY = event.clientY - containerRect.top;
            
            // ツールチップを表示
            tooltip
              .style('display', 'block')
              .style('opacity', 1)
              .style('left', `${mouseX + 10}px`)
              .style('top', `${mouseY - 40}px`)
              .html(`
                <div class="font-bold">${prefName}</div>
                <div>${value}</div>
              `);
          }
        })
        .on('mousemove', function(event) {
          // マウス移動時の処理
          const containerRect = containerRef.current?.getBoundingClientRect();
          if (containerRect) {
            const mouseX = event.clientX - containerRect.left;
            const mouseY = event.clientY - containerRect.top;
            
            tooltip
              .style('left', `${mouseX + 10}px`)
              .style('top', `${mouseY - 40}px`);
          }
        })
        .on('mouseout', function() {
          // マウスアウト時の処理
          d3.select(this)
            .attr('stroke', isDarkMode ? '#555' : 'white')
            .attr('stroke-width', 0.5);
          
          // ツールチップを非表示
          tooltip
            .style('opacity', 0)
            .style('display', 'none');
        });
      
      // ツールチップのスタイル調整
      tooltip
        .style('background-color', isDarkMode ? '#333' : 'white')
        .style('color', isDarkMode ? 'white' : 'black')
        .style('border-color', isDarkMode ? '#555' : '#ddd');
      
      // データがある場合のみ凡例を表示
      if (PREFECTURE_DATA.length > 0) {
        // 縦凡例の設定
        const legendWidth = 20;
        const legendHeight = 120;
        const legendX = width - legendWidth - 50;
        const legendY = height - legendHeight - 40;
        
        // 凡例用のスケール
        const legendScale = d3.scaleLinear()
          .domain([d3.max(Array.from(prefectureData.values())) || 0, 0])
          .range([0, legendHeight]);
        
        // 凡例の目盛りを固定値に設定
        const legendTicks = [0, 500, 1000, 1500];
        const maxValue = d3.max(Array.from(prefectureData.values())) || 0;
        const usableTicks = legendTicks.filter(tick => tick <= maxValue);
        if (usableTicks[usableTicks.length - 1] < maxValue) {
          usableTicks.push(maxValue);
        }
        
        const legendAxis = d3.axisRight(legendScale)
          .tickValues(usableTicks)
          .tickSize(2)
          .tickFormat(d => {
            // 数値を適切なフォーマットで表示
            const value = +d;
            if (value >= 1000) {
              return `${(value / 1000).toFixed(1)}k`;
            }
            return `${value}`;
          });
        
        // グラデーション定義
        const defs = svg.append('defs');
        const linearGradient = defs.append('linearGradient')
          .attr('id', 'legend-gradient')
          .attr('x1', '0%')
          .attr('y1', '0%')
          .attr('x2', '0%')
          .attr('y2', '100%');
        
        linearGradient.selectAll('stop')
          .data(d3.range(0, 1.01, 0.1))
          .join('stop')
          .attr('offset', d => d * 100 + '%')
          .attr('stop-color', d => colorScale((1 - d) * (d3.max(Array.from(prefectureData.values())) || 0)));
        
        // 凡例の矩形
        svg.append('rect')
          .attr('x', legendX)
          .attr('y', legendY)
          .attr('width', legendWidth)
          .attr('height', legendHeight)
          .style('fill', 'url(#legend-gradient)');
        
        // 凡例の軸
        const legendAxisG = svg.append('g')
          .attr('transform', `translate(${legendX + legendWidth}, ${legendY})`)
          .call(legendAxis as d3.Axis<d3.NumberValue>);
        
        // 凡例の軸のテキストサイズを小さく
        legendAxisG.selectAll('text')
          .attr('font-size', '8px')
          .attr('dx', '1px');
        
        // 凡例のタイトル
        svg.append('text')
          .attr('x', legendX + legendWidth / 2)
          .attr('y', legendY - 6)
          .attr('text-anchor', 'middle')
          .attr('font-size', '8px')
          .attr('fill', isDarkMode ? '#e5e7eb' : '#333')
          .text(MAP_UNIT);
      }
      
      // タイトルを追加
      svg.append('text')
        .attr('x', width / 2)
        .attr('y', 20)
        .attr('text-anchor', 'middle')
        .attr('font-size', '14px')
        .attr('font-weight', 'bold')
        .attr('fill', isDarkMode ? '#e5e7eb' : '#333')
        .text(MAP_TITLE);
      
    } catch (error) {
      console.error('地図の描画中にエラーが発生しました:', error);
    }
  }, [topoData, isDarkMode, colorScheme]);

  return (
    <div ref={containerRef} className="relative my-8">
      <svg ref={svgRef} className="w-full h-auto"></svg>
      <div 
        ref={tooltipRef} 
        className={`absolute p-2 rounded shadow-lg border text-sm pointer-events-none opacity-0 z-10 ${
          isDarkMode ? 'bg-gray-800 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-200'
        }`}
        style={{ 
          display: 'none',
          left: 0, 
          top: 0,
          minWidth: '120px'
        }}
      ></div>
    </div>
  );
}

実装の基本構造

必要なライブラリとインポート

'use client';

import { useRef, useEffect, useState } from 'react';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import { Topology, GeometryObject, GeometryCollection } from 'topojson-specification';
import { Feature, FeatureCollection, Geometry, GeoJsonProperties } from 'geojson';
import { GeoPath, GeoPermissibleObjects } from 'd3-geo';
import { useTheme } from 'next-themes';
  • 'use client' ディレクティブでクライアントコンポーネントとして宣言
  • Reactのフックとして useRef, useEffect, useState を使用
  • D3.jsとTopoJSON関連のライブラリをインポート
  • Next.jsの next-themes からダークモード対応のための useTheme フックをインポート

都道府県データの定義

// 都道府県コードと名前のマッピング
const PREFECTURE_MAP: Record<string, string> = {
  "01": "北海道", "02": "青森県", "03": "岩手県", "04": "宮城県", "05": "秋田県",
  "06": "山形県", "07": "福島県", "08": "茨城県", "09": "栃木県", "10": "群馬県",
  "11": "埼玉県", "12": "千葉県", "13": "東京都", "14": "神奈川県", "15": "新潟県",
  "16": "富山県", "17": "石川県", "18": "福井県", "19": "山梨県", "20": "長野県",
  "21": "岐阜県", "22": "静岡県", "23": "愛知県", "24": "三重県", "25": "滋賀県",
  "26": "京都府", "27": "大阪府", "28": "兵庫県", "29": "奈良県", "30": "和歌山県",
  "31": "鳥取県", "32": "島根県", "33": "岡山県", "34": "広島県", "35": "山口県",
  "36": "徳島県", "37": "香川県", "38": "愛媛県", "39": "高知県", "40": "福岡県",
  "41": "佐賀県", "42": "長崎県", "43": "熊本県", "44": "大分県", "45": "宮崎県",
  "46": "鹿児島県", "47": "沖縄県"
};

// 都道府県名と都道府県コードのマッピング(逆引き用)
const PREFECTURE_NAME_TO_CODE: Record<string, string> = Object.entries(PREFECTURE_MAP)
  .reduce((acc, [code, name]) => ({ ...acc, [name]: code }), {});

// タイトル、データ、単位を外部で定義
const MAP_TITLE = "都道府県別人口(2023年)";
const MAP_UNIT = "万人";

// 全都道府県のデータ
const PREFECTURE_DATA = [
  { prefName: "北海道", value: 520 },
  { prefName: "青森県", value: 124 },
  { prefName: "岩手県", value: 121 },
  { prefName: "宮城県", value: 230 },
  { prefName: "秋田県", value: 96 },
  { prefName: "山形県", value: 107 },
  { prefName: "福島県", value: 184 },
  { prefName: "茨城県", value: 287 },
  { prefName: "栃木県", value: 194 },
  { prefName: "群馬県", value: 195 },
  { prefName: "埼玉県", value: 730 },
  { prefName: "千葉県", value: 624 },
  { prefName: "東京都", value: 1400 },
  { prefName: "神奈川県", value: 920 },
  { prefName: "新潟県", value: 223 },
  { prefName: "富山県", value: 105 },
  { prefName: "石川県", value: 114 },
  { prefName: "福井県", value: 77 },
  { prefName: "山梨県", value: 81 },
  { prefName: "長野県", value: 206 },
  { prefName: "岐阜県", value: 199 },
  { prefName: "静岡県", value: 364 },
  { prefName: "愛知県", value: 750 },
  { prefName: "三重県", value: 179 },
  { prefName: "滋賀県", value: 141 },
  { prefName: "京都府", value: 259 },
  { prefName: "大阪府", value: 880 },
  { prefName: "兵庫県", value: 550 },
  { prefName: "奈良県", value: 135 },
  { prefName: "和歌山県", value: 93 },
  { prefName: "鳥取県", value: 56 },
  { prefName: "島根県", value: 67 },
  { prefName: "岡山県", value: 190 },
  { prefName: "広島県", value: 282 },
  { prefName: "山口県", value: 137 },
  { prefName: "徳島県", value: 73 },
  { prefName: "香川県", value: 97 },
  { prefName: "愛媛県", value: 135 },
  { prefName: "高知県", value: 70 },
  { prefName: "福岡県", value: 510 },
  { prefName: "佐賀県", value: 82 },
  { prefName: "長崎県", value: 134 },
  { prefName: "熊本県", value: 175 },
  { prefName: "大分県", value: 114 },
  { prefName: "宮崎県", value: 108 },
  { prefName: "鹿児島県", value: 160 },
  { prefName: "沖縄県", value: 146 }
];
  • 都道府県コードと名前の対応マップを定義
  • 都道府県名からコードを逆引きするためのマップも用意
  • 地図のタイトル、単位、データをコンポーネント外で定義

コンポーネントの型定義

// TopoJSONの型定義
interface JapanTopoJSON extends Topology {
  objects: {
    [key: string]: GeometryObject | GeometryCollection;
  };
}

interface JapanMapProps {
  colorScheme?: 'blue' | 'green' | 'red' | 'purple' | 'orange';
}
  • TopoJSONデータの型を定義
  • コンポーネントのプロップスとして、カラースキームを受け取るように定義

コンポーネントの実装

基本構造とフック

export default function PrefectureRankTotalPopulationMap({
  colorScheme = 'blue'
}: JapanMapProps) {
  const svgRef = useRef<SVGSVGElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const { resolvedTheme } = useTheme();
  const isDarkMode = resolvedTheme === 'dark';
  const [topoData, setTopoData] = useState<JapanTopoJSON | null>(null);

  // ...
}
  • SVG、ツールチップ、コンテナ要素への参照を useRef で管理
  • useTheme フックでダークモードの状態を取得
  • TopoJSONデータを useState で管理

カラースキームの設定

const getColorInterpolator = (scheme: string, isDark: boolean) => {
  switch (scheme) {
    case 'green': return isDark ? d3.interpolateGnBu : d3.interpolateGreens;
    case 'red': return isDark ? d3.interpolateRdPu : d3.interpolateReds;
    case 'purple': return isDark ? d3.interpolatePuRd : d3.interpolatePurples;
    case 'orange': return isDark ? d3.interpolateOrRd : d3.interpolateOranges;
    case 'blue':
    default: return isDark ? d3.interpolateBlues : d3.interpolateBlues;
  }
};
  • 指定されたカラースキームとダークモードの状態に基づいて、適切なD3のカラーインターポレーターを返す
  • 各カラースキームにはダークモード用と通常モード用の2種類を用意

データの読み込み

useEffect(() => {
  fetch('/data/japan-topojson.json')
    .then(response => response.json())
    .then(data => {
      setTopoData(data);
    })
    .catch(error => {
      console.error('地図データの読み込みに失敗しました:', error);
    });
}, []);
  • useEffect を使用して、コンポーネントのマウント時に一度だけTopoJSONデータを読み込む
  • 読み込んだデータは setTopoData で状態に保存

地図の描画

useEffect(() => {
  if (!svgRef.current || !topoData) return;

  try {
    // TopoJSONからGeoJSONへの変換
    const objectKey = Object.keys(topoData?.objects || {})[0];
    if (!objectKey) {
      throw new Error('地図データの形式が正しくありません');
    }
    
    const japanGeo = feature(topoData as Topology, topoData.objects[objectKey]);
    const japan = japanGeo as unknown as FeatureCollection<Geometry>;
    
    // データの結合
    const prefectureData = new Map<string, number>();
    
    // データを都道府県コードとマッピング
    PREFECTURE_DATA.forEach(d => {
      const prefCode = PREFECTURE_NAME_TO_CODE[d.prefName];
      if (prefCode) {
        prefectureData.set(prefCode, d.value);
      }
    });
    
    // SVGの設定
    const width = 600;
    const height = 500;
    const svg = d3.select(svgRef.current)
      .attr('width', width)
      .attr('height', height)
      .attr('viewBox', [0, 0, width, height])
      .attr('style', 'max-width: 100%; height: auto;');
    
    // 既存のSVG要素をクリア
    svg.selectAll("*").remove();
    
    // 地図の投影法
    const projection = d3.geoMercator()
      .center([137, 38])
      .scale(1600)
      .translate([width / 2, height / 2]);
    
    // パスジェネレータ
    const path = d3.geoPath().projection(projection);
    
    // カラースケール
    const colorInterpolator = getColorInterpolator(colorScheme, isDarkMode);
    const colorScale = d3.scaleSequential()
      .domain([0, d3.max(Array.from(prefectureData.values())) || 0])
      .interpolator(colorInterpolator);
    
    // ツールチップの設定
    const tooltip = d3.select(tooltipRef.current);
    
    // 地図の描画
    svg.selectAll('path')
      .data(japan.features)
      .join('path')
      .attr('fill', (d, i) => {
        // インデックスから都道府県コードを取得
        const prefCode = String(i + 1).padStart(2, '0');
        // 都道府県コードから値を取得
        return prefectureData.has(prefCode) 
          ? colorScale(prefectureData.get(prefCode) || 0) 
          : isDarkMode ? '#444' : '#eee';  // データがない場合の色
      })
      .attr('d', path as GeoPath<GeoJsonProperties, GeoPermissibleObjects>)
      .attr('stroke', isDarkMode ? '#555' : 'white')
      .attr('stroke-width', 0.5)
      .attr('class', 'prefecture')
      .on('mouseover', function(event, d) {
        // 型アサーションを使用
        const feature = d as Feature<Geometry, GeoJsonProperties>;
        // インデックスを取得
        const index = japan.features.indexOf(feature);
        const prefCode = String(index + 1).padStart(2, '0');
        const prefName = PREFECTURE_MAP[prefCode] || '不明';
        
        // 値を取得
        let value = '該当データなし';
        
        if (prefectureData.has(prefCode)) {
          value = (prefectureData.get(prefCode) || 0) + MAP_UNIT;
        }
        
        // 要素をハイライト
        d3.select(this)
          .attr('stroke', '#333')
          .attr('stroke-width', 2);
        
        // コンテナの位置を取得
        const containerRect = containerRef.current?.getBoundingClientRect();
        if (containerRect) {
          const mouseX = event.clientX - containerRect.left;
          const mouseY = event.clientY - containerRect.top;
          
          // ツールチップを表示
          tooltip
            .style('display', 'block')
            .style('opacity', 1)
            .style('left', `${mouseX + 10}px`)
            .style('top', `${mouseY - 40}px`)
            .html(`
              <div class="font-bold">${prefName}</div>
              <div>${value}</div>
            `);
        }
      })
      .on('mousemove', function(event) {
        // マウス移動時の処理
        const containerRect = containerRef.current?.getBoundingClientRect();
        if (containerRect) {
          const mouseX = event.clientX - containerRect.left;
          const mouseY = event.clientY - containerRect.top;
          
          tooltip
            .style('left', `${mouseX + 10}px`)
            .style('top', `${mouseY - 40}px`);
        }
      })
      .on('mouseout', function() {
        // マウスアウト時の処理
        d3.select(this)
          .attr('stroke', isDarkMode ? '#555' : 'white')
          .attr('stroke-width', 0.5);
        
        // ツールチップを非表示
        tooltip
          .style('opacity', 0)
          .style('display', 'none');
      });
    
    // ツールチップのスタイル調整
    tooltip
      .style('background-color', isDarkMode ? '#333' : 'white')
      .style('color', isDarkMode ? 'white' : 'black')
      .style('border-color', isDarkMode ? '#555' : '#ddd');
    
    // データがある場合のみ凡例を表示
    if (PREFECTURE_DATA.length > 0) {
      // 縦凡例の設定
      const legendWidth = 20;
      const legendHeight = 120;
      const legendX = width - legendWidth - 50;
      const legendY = height - legendHeight - 40;
      
      // 凡例用のスケール
      const legendScale = d3.scaleLinear()
        .domain([d3.max(Array.from(prefectureData.values())) || 0, 0])
        .range([0, legendHeight]);
      
      // 凡例の目盛りを固定値に設定
      const legendTicks = [0, 500, 1000, 1500];
      const maxValue = d3.max(Array.from(prefectureData.values())) || 0;
      const usableTicks = legendTicks.filter(tick => tick <= maxValue);
      if (usableTicks[usableTicks.length - 1] < maxValue) {
        usableTicks.push(maxValue);
      }
      
      const legendAxis = d3.axisRight(legendScale)
        .tickValues(usableTicks)
        .tickSize(2)
        .tickFormat(d => {
          // 数値を適切なフォーマットで表示
          const value = +d;
          if (value >= 1000) {
            return `${(value / 1000).toFixed(1)}k`;
          }
          return `${value}`;
        });
      
      // グラデーション定義
      const defs = svg.append('defs');
      const linearGradient = defs.append('linearGradient')
        .attr('id', 'legend-gradient')
        .attr('x1', '0%')
        .attr('y1', '0%')
        .attr('x2', '0%')
        .attr('y2', '100%');
      
      linearGradient.selectAll('stop')
        .data(d3.range(0, 1.01, 0.1))
        .join('stop')
        .attr('offset', d => d * 100 + '%')
        .attr('stop-color', d => colorScale((1 - d) * (d3.max(Array.from(prefectureData.values())) || 0)));
      
      // 凡例の矩形
      svg.append('rect')
        .attr('x', legendX)
        .attr('y', legendY)
        .attr('width', legendWidth)
        .attr('height', legendHeight)
        .style('fill', 'url(#legend-gradient)');
      
      // 凡例の軸
      const legendAxisG = svg.append('g')
        .attr('transform', `translate(${legendX + legendWidth}, ${legendY})`)
        .call(legendAxis as d3.Axis<d3.NumberValue>);
      
      // 凡例の軸のテキストサイズを小さく
      legendAxisG.selectAll('text')
        .attr('font-size', '8px')
        .attr('dx', '1px');
      
      // 凡例のタイトル
      svg.append('text')
        .attr('x', legendX + legendWidth / 2)
        .attr('y', legendY - 6)
        .attr('text-anchor', 'middle')
        .attr('font-size', '8px')
        .attr('fill', isDarkMode ? '#e5e7eb' : '#333')
        .text(MAP_UNIT);
    }
    
    // タイトルを追加
    svg.append('text')
      .attr('x', width / 2)
      .attr('y', 20)
      .attr('text-anchor', 'middle')
      .attr('font-size', '14px')
      .attr('font-weight', 'bold')
      .attr('fill', isDarkMode ? '#e5e7eb' : '#333')
      .text(MAP_TITLE);
    
  } catch (error) {
    console.error('地図の描画中にエラーが発生しました:', error);
  }
}, [topoData, isDarkMode, colorScheme]);
  • topoData, isDarkMode, colorScheme が変更されたときに地図を再描画
  • TopoJSONデータをGeoJSONに変換
  • 都道府県データを都道府県コードとマッピング
  • SVGの基本設定と既存要素のクリア
  • メルカトル図法を使用して日本地図を投影
  • 選択されたカラースキームとダークモードの状態に基づいてカラーインターポレーターを取得
  • D3のデータバインディングを使用して、GeoJSONの各フィーチャーにパスを作成
  • ホバー、マウス移動、マウスアウト時のイベントハンドラを設定
  • データの値に応じた色の凡例を作成

コンポーネントのレンダリング

return (
  <div ref={containerRef} className="relative my-8">
    <svg ref={svgRef} className="w-full h-auto"></svg>
    <div 
      ref={tooltipRef} 
      className={`absolute p-2 rounded shadow-lg border text-sm pointer-events-none opacity-0 z-10 ${
        isDarkMode ? 'bg-gray-800 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-200'
      }`}
      style={{ 
        display: 'none',
        left: 0, 
        top: 0,
        minWidth: '120px'
      }}
    ></div>
  </div>
);
  • コンテナ、SVG、ツールチップの要素を配置
  • ダークモードに応じたスタイリングを適用

カスタマイズ方法

このコンポーネントは様々な方法でカスタマイズできます:

異なるデータセットの使用

// 別のデータセットを定義
const PREFECTURE_DATA_DENSITY = [
  { prefName: "東京都", value: 6400 },  // 人口密度(人/km²)
  // 他の都道府県データ
];

// コンポーネントにデータを渡す
<PrefectureRankTotalPopulationMap data={PREFECTURE_DATA_DENSITY} title="都道府県別人口密度" unit="人/km²" />

カラースキームの変更

// 緑系のカラースキームを使用
<PrefectureRankTotalPopulationMap colorScheme="green" />

// 赤系のカラースキームを使用
<PrefectureRankTotalPopulationMap colorScheme="red" />

地図の投影法のカスタマイズ

// 投影法の設定をカスタマイズ
const projection = d3.geoMercator()
  .center([135, 35])  // 中心座標を変更
  .scale(2000)        // スケールを変更
  .translate([width / 2, height / 2]);

インタラクションの拡張

// クリックイベントの追加
.on('click', function(event, d) {
  // クリックした都道府県の詳細情報を表示するなどの処理
  const feature = d as Feature<Geometry, GeoJsonProperties>;
  const index = japan.features.indexOf(feature);
  const prefCode = String(index + 1).padStart(2, '0');
  const prefName = PREFECTURE_MAP[prefCode] || '不明';
  
  console.log(`${prefName}がクリックされました`);
  // 詳細情報を表示する処理など
})

まとめ

Next.jsとD3.jsを組み合わせることで、インタラクティブな日本地図コンポーネントを実装することができました。このコンポーネントは以下の特徴を持っています:

  • クライアントサイドでのインタラクティブな可視化
  • ダークモード対応
  • 複数のカラースキーム
  • ホバー時のツールチップ表示
  • レスポンシブデザイン

このようなデータ可視化コンポーネントは、地域ごとの統計データを直感的に理解するのに非常に効果的です。Next.jsの最新機能とD3.jsの強力な可視化機能を組み合わせることで、より洗練されたデータビジュアライゼーションを実現できます。

今回紹介したコードをベースに、さまざまなデータセットやカスタマイズを加えることで、あなた独自のデータ可視化コンポーネントを作成してみてください。