エンジニアのスキルマップをグラフ構造で描きたい

# 背景
* エンジニア業務では複数のスキルが求められる
  * コーディング能力、アーキテクチャ設計能力、コミュニケーション能力などなど


- これらのスキル間には因果関係や重みづけが存在する
    * 学ぶ順序もある。順方向のみでない。多方向のものもある
    * ２次元マップでは表現ができない😭😭😭

## 対応策

グラフ構造でスキルマップ描く。重みづけも方向も表現できる！😊😊😊

## 概要

* スキルをグラフ構造でモデル化（スキルグラフ）
  * 各能力をスキルとしてノードに定義
  * スキル間の因果関係をエッジで表現

### イメージこんな感じ


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/c0ffd3a2-dcd0-4dc9-b76a-d562e19fa5df.png)

# 使用するライブラリ

[d3.js](https://d3js.org/)をつかいます。

https://observablehq.com/@sugi2000/d3-tutorial-japanese


# 実装

### データ部

```typescript

  const skillData = {
    nodes: [
      // 基本系スキル
      // mr: 習熟度(Mastery Rate), cr: 重要度(Criticality Rate)
      { id: 'js_syntax', label: 'JavaScript文法', mr: 0.90, cr: 0.60, category: 'basic' },
      { id: 'git', label: 'Git操作', mr: 0.85, cr: 0.65, category: 'basic' },
      { id: 'terminal', label: 'ターミナル操作', mr: 0.82, cr: 0.60, category: 'basic' },
      { id: 'html_css', label: 'HTML/CSS', mr: 0.88, cr: 0.65, category: 'basic' },
      { id: 'npm', label: 'パッケージ管理', mr: 0.80, cr: 0.70, category: 'basic' },
      
      // 実践系スキル
      { id: 'component_dev', label: 'コンポーネント開発', mr: 0.75, cr: 0.80, category: 'practical' },
      { id: 'state_management', label: '状態管理', mr: 0.72, cr: 0.85, category: 'practical' },
      { id: 'api_integration', label: 'API連携', mr: 0.70, cr: 0.80, category: 'practical' },
      { id: 'testing', label: 'テスト実装', mr: 0.68, cr: 0.75, category: 'practical' },
      { id: 'debugging', label: 'デバッグ', mr: 0.73, cr: 0.80, category: 'practical' },
      
      // 戦略系スキル
      { id: 'arch_design', label: 'アーキテクチャ設計', mr: 0.60, cr: 0.90, category: 'strategic' },
      { id: 'perf_optimization', label: 'パフォーマンス最適化', mr: 0.65, cr: 0.85, category: 'strategic' },
      { id: 'security', label: 'セキュリティ設計', mr: 0.58, cr: 0.88, category: 'strategic' },
      { id: 'proj_management', label: 'プロジェクト管理', mr: 0.62, cr: 0.85, category: 'strategic' },
      { id: 'tech_selection', label: '技術選定', mr: 0.64, cr: 0.87, category: 'strategic' }
    ],
    edges: [
      // 基本→基本の関係
      { source: 'js_syntax', target: 'html_css', weight: 0.5 },
      { source: 'terminal', target: 'git', weight: 0.7 },
      { source: 'npm', target: 'git', weight: 0.6 },
      
      // 基本→実践の関係
      { source: 'js_syntax', target: 'component_dev', weight: 0.9 },
      { source: 'js_syntax', target: 'debugging', weight: 0.8 },
      { source: 'html_css', target: 'component_dev', weight: 0.7 },
      { source: 'npm', target: 'api_integration', weight: 0.6 },
      
      // 実践→実践の関係
      { source: 'component_dev', target: 'state_management', weight: 0.8 },
      { source: 'api_integration', target: 'state_management', weight: 0.7 },
      { source: 'debugging', target: 'testing', weight: 0.6 },
      
      // 実践→戦略の関係
      { source: 'component_dev', target: 'arch_design', weight: 0.7 },
      { source: 'state_management', target: 'perf_optimization', weight: 0.8 },
      { source: 'testing', target: 'security', weight: 0.6 },
      { source: 'api_integration', target: 'tech_selection', weight: 0.5 },
      
      // 戦略→戦略の関係
      { source: 'arch_design', target: 'perf_optimization', weight: 0.7 },
      { source: 'security', target: 'arch_design', weight: 0.8 },
      { source: 'tech_selection', target: 'proj_management', weight: 0.6 }
    ]
  };
```

### 描画部

```typescript

import React, { useEffect, useState, useRef } from 'react';
import * as d3 from 'd3';

// スキルグラフのメインコンポーネント
const ComprehensiveSkillGraph = () => {
  // SVG要素への参照を保持するref
  const svgRef = useRef(null);
  // 選択されたノードの状態を管理
  const [selectedNode, setSelectedNode] = useState(null);
  
  const skillData = { //省略
  };

  useEffect(() => {
      // SVG要素が存在しない場合は処理を中断
      if (!svgRef.current) return;
  
      // 既存のSVG要素内のコンテンツをクリア
      d3.select(svgRef.current).selectAll("*").remove();
  
      // SVGのビューポートサイズを設定
      const width = 800;
      const height = 600;
      const svg = d3.select(svgRef.current);
      
      // カテゴリーごとの中心位置を設定
      // 画面を3分割し、各カテゴリーを水平方向に配置
      const categoryPositions = {
        basic: { x: width * 0.2, y: height * 0.5 },      // 左側: 基本スキル
        practical: { x: width * 0.5, y: height * 0.5 },   // 中央: 実践スキル
        strategic: { x: width * 0.8, y: height * 0.5 }    // 右側: 戦略スキル
      };
  
      // D3.jsのForce Simulationの設定
      // ノードの配置とインタラクションを物理シミュレーションで制御
      const simulation = d3.forceSimulation(skillData.nodes)
        // リンク（エッジ）の力を設定：関連の強さに応じて距離を調整
        .force('link', d3.forceLink(skillData.edges)
          .id(d => d.id)
          .distance(d => 120 - d.weight * 40))  // weightが大きいほど近づく
        // ノード間の反発力を設定：-200は反発の強さ
        .force('charge', d3.forceManyBody().strength(-200))
        // X軸方向の力：各カテゴリーの中心に向かう力
        .force('x', d3.forceX().x(d => categoryPositions[d.category].x).strength(0.1))
        // Y軸方向の力：垂直方向の中心に向かう力
        .force('y', d3.forceY().y(d => categoryPositions[d.category].y).strength(0.1))
        // ノード同士の衝突を防ぐ力：半径50pxの範囲で衝突判定
        .force('collision', d3.forceCollide().radius(50));


      // 矢印マーカーの定義
      // エッジの終端に表示される矢印の形状を定義
      svg.append('defs').append('marker')
        .attr('id', 'arrowhead')
        .attr('viewBox', '-0 -5 10 10')  // 矢印の表示領域を設定
        .attr('refX', 30)  // 矢印の位置調整（ノードとの距離）
        .attr('refY', 0)   // 矢印の垂直位置
        .attr('orient', 'auto')  // 矢印の向きを自動調整
        .attr('markerWidth', 6)  // 矢印の幅
        .attr('markerHeight', 6) // 矢印の高さ
        .append('path')
        .attr('d', 'M 0,-5 L 10,0 L 0,5')  // 矢印の形状をパスで定義
        .attr('fill', '#999');  // 矢印の色
  
      // SVGコンテナの作成
      // ズームとパンに対応するためのグループ要素
      const container = svg.append('g');
  
      // ズーム機能の追加
      const zoom = d3.zoom()
        .scaleExtent([0.5, 2])  // ズームの範囲を制限 0.5倍から2倍までのズームを許可
        .on('zoom', (event) => {
          // ズーム時にコンテナ全体を変換
          container.attr('transform', event.transform);
        });
      svg.call(zoom);  // SVG要素にズーム機能を適用
  
      // カテゴリー背景の作成
      // 各スキルカテゴリーの領域を視覚的に区分
      const categoryGroups = container.selectAll('.category-group')
        .data(['basic', 'practical', 'strategic'])  // カテゴリーデータをバインド
        .enter()
        .append('g')
        .attr('class', 'category-group');
  
      // カテゴリー領域の背景矩形を追加
      // 半透明の背景色で各カテゴリーを区分
      categoryGroups.append('rect')
        .attr('x', d => categoryPositions[d].x - 150)  // 中心から左右に150pxの幅
        .attr('y', 50)  // 上端の余白
        .attr('width', 300)  // 領域の幅
        .attr('height', height - 100)  // 領域の高さ
        .attr('fill', d => {
          // カテゴリーごとに異なる色を設定（20%の不透明度）
          switch(d) {
            case 'basic': return '#8884d820';
            case 'practical': return '#82ca9d20';
            case 'strategic': return '#ffc65820';
          }
        })
        .attr('rx', 10);  // 角を丸める
  
      // カテゴリーラベルの追加
      categoryGroups.append('text')
        .attr('x', d => categoryPositions[d].x)  // カテゴリーの中心に配置
        .attr('y', 30)  // 上端からの距離
        .attr('text-anchor', 'middle')  // テキストを中央揃え
        .style('font-size', '16px')
        .style('fill', '#666')
        .text(d => {
          // カテゴリー名を日本語で表示
          switch(d) {
            case 'basic': return '基本系スキル';
            case 'practical': return '実践系スキル';
            case 'strategic': return '戦略系スキル';
          }
        });
  
    // エッジの作成
    // スキル間の関連性を示す矢印付きの線を描画
    const edges = container.append('g')
      .selectAll('line')
      .data(skillData.edges)  // エッジデータをD3にバインド
      .join('line')  // 新しい線要素を作成
      .style('stroke', '#999')  // 線の色を設定
      .style('stroke-opacity', 0.6)  // 線を半透明に
      .style('stroke-width', d => d.weight * 3)  // 関連の強さに応じて線の太さを変更
      .attr('marker-end', 'url(#arrowhead)');  // 線の終端に矢印を追加

    // ノードグループの作成
    // 各スキルを表す円とラベルのコンテナ
    const nodeGroups = container.append('g')
      .selectAll('g')
      .data(skillData.nodes)  // ノードデータをD3にバインド
      .join('g')  // グループ要素を作成
      .call(d3.drag()  // ドラッグ&ドロップ機能を追加
        .on('start', dragstarted)  // ドラッグ開始時の処理
        .on('drag', dragged)      // ドラッグ中の処理
        .on('end', dragended));   // ドラッグ終了時の処理

    // ノードの円を作成
    // スキルの種類に応じてサイズと色を変更する円を描画
    nodeGroups.append('circle')
      .attr('r', d => {
        // カテゴリーごとに異なる円の大きさを設定
        switch(d.category) {
          case 'basic': return 25;     // 基本スキルは小さめ
          case 'practical': return 30;  // 実践スキルは中程度
          case 'strategic': return 35;  // 戦略スキルは大きめ
          default: return 25;
        }
      })
      .style('fill', d => {
        // カテゴリーごとに異なる色を設定
        switch(d.category) {
          case 'basic': return '#8884d8';     // 紫色: 基本スキル
          case 'practical': return '#82ca9d';  // 緑色: 実践スキル
          case 'strategic': return '#ffc658';  // 黄色: 戦略スキル
          default: return '#grey';
        }
      })
      .style('fill-opacity', 0.7)  // 円を半透明に
      .style('stroke', '#fff')     // 円の縁を白に
      .style('stroke-width', 2)    // 縁の太さを2pxに
      .on('click', (event, d) => setSelectedNode(d));  // クリックでノードを選択

    // ノードのラベルを作成
    nodeGroups.append('text')
      .text(d => d.label)          // ノードのラベルテキストを設定
      .attr('text-anchor', 'middle')  // テキストを中央揃え
      .attr('dy', '.35em')         // テキストを垂直方向に微調整
      .style('font-size', '10px')  // フォントサイズを10pxに
      .style('fill', 'white')      // テキストの色を白に
      .style('pointer-events', 'none');  // テキストへのマウスイベントを無効化

    // シミュレーションの更新処理
    // フレームごとにノードとエッジの位置を更新
    simulation.on('tick', () => {
      // エッジの位置を更新
      edges
        .attr('x1', d => d.source.x)  // 開始点のX座標
        .attr('y1', d => d.source.y)  // 開始点のY座標
        .attr('x2', d => d.target.x)  // 終点のX座標
        .attr('y2', d => d.target.y); // 終点のY座標

      // ノードの位置を更新（グループごと移動）
      nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`);
    });

    // ドラッグ開始時の処理
    function dragstarted(event) {
      if (!event.active) simulation.alphaTarget(0.3).restart();  // シミュレーションを再開
      event.subject.fx = event.subject.x;  // X座標を固定
      event.subject.fy = event.subject.y;  // Y座標を固定
    }

    // ドラッグ中の処理
    function dragged(event) {
      event.subject.fx = event.x;  // ドラッグ位置にX座標を更新
      event.subject.fy = event.y;  // ドラッグ位置にY座標を更新
    }

    // ドラッグ終了時の処理
    function dragended(event) {
      if (!event.active) simulation.alphaTarget(0);  // シミュレーションを徐々に停止
      event.subject.fx = null;  // X座標の固定を解除
      event.subject.fy = null;  // Y座標の固定を解除
    }

    return () => {
      simulation.stop();
    };
  }, []);

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <div className="relative">
        <svg
          ref={svgRef}
          className="w-full h-[600px] bg-white rounded-lg shadow"
          viewBox="0 0 800 600"
        />
        
        {selectedNode && (
          <div className="absolute top-4 right-4 bg-white p-4 rounded-lg shadow">
            <h3 className="font-bold text-lg mb-2">{selectedNode.label}</h3>
            <div className="space-y-2">
              <div>
                <span className="text-gray-600">マスタリーレート:</span>
                <div className="w-full bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-blue-600 h-2 rounded-full"
                    style={{ width: `${selectedNode.mr * 100}%` }}
                  />
                </div>
                <span className="text-sm text-gray-500">{Math.round(selectedNode.mr * 100)}%</span>
              </div>
              <div>
                <span className="text-gray-600">チャレンジレート:</span>
                <div className="w-full bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-green-600 h-2 rounded-full"
                    style={{ width: `${selectedNode.cr * 100}%` }}
                  />
                </div>
                <span className="text-sm text-gray-500">{Math.round(selectedNode.cr * 100)}%</span>
              </div>
            </div>
          </div>
        )}
      </div>

      <div className="mt-4 p-4 bg-gray-100 rounded-lg">
        <div className="space-y-2">
          <div className="flex space-x-4">
            <div className="flex items-center">
              <div className="w-4 h-4 rounded-full bg-[#8884d8] opacity-70 mr-2" />
              <span>基本系スキル: プログラミング言語、ツール操作等</span>
            </div>
          </div>
          <div className="flex space-x-4">
            <div className="flex items-center">
              <div className="w-4 h-4 rounded-full bg-[#82ca9d] opacity-70 mr-2" />
              <span>実践系スキル: 基本スキルを応用した実装能力</span>
            </div>
          </div>
          <div className="flex space-x-4">
            <div className="flex items-center">
              <div className="w-4 h-4 rounded-full bg-[#ffc658] opacity-70 mr-2" />
              <span>戦略系スキル: アーキテクチャ設計、プロジェクト管理等</span>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default SkillNetworkGraph;
```

#

というプログラムをclaudeが書いてくれました。

https://claude.site/artifacts/bcd77d15-faf3-4270-b849-524aefc2c79d

データ部を書き換えれば、いろんなスキルグラフが描けますね。
Have a nice graph coding!👋👋👋