0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【D3.js】野村総研+WEF+総務省データで70職種のAI影響度スコアを可視化してみた(forceSimulation + WordPress埋め込み)

0
Posted at

ai-job-impact-eyecatch01.jpg

この記事でできること

  • 異なる複数の公開統計データを組み合わせて職種別スコアを算出する手順を理解できる
  • D3.js v7のforceSimulationで70バブルの重なりを解消する実装ができる
  • カテゴリ絞り込み時に位置固定・透過度変更でフィルタリングするパターンを実装できる
  • WordPress(SWELLテーマ)への埋め込みで詰まる2大問題(&&変換・CDN無視)を回避できる

実物のツールはAI時代の職業図鑑(AI Japan Index)で確認できる。

環境・前提

  • D3.js: v7.9.0
  • JavaScript: ES2020+(Vanilla JS、フレームワーク不使用)
  • 動作確認ブラウザ: Chrome 122+、Firefox 123+、Safari 17+、Edge 122+
  • ホスティング: WordPress 6.4 + SWELLテーマ 2.6.x(インラインHTML埋め込み)
  • データソース: 野村総研+オックスフォード大(2015年)、WEF Future of Jobs 2025(2025年1月)、総務省 労働力調査(2026年1月公表)、厚労省 job tag(2025年版)
  • 対象職種数: 70職種

完成形

70職種のAI影響度スコア(0〜100点)をバブルチャートで可視化。X軸がAI代替確率、Y軸が共存指数、バブルサイズが就業者数、バブル色がAI Impact Score(緑=低リスク / 赤=高リスク)の4次元表現。カテゴリタブで絞り込み、職種名での検索、下部テーブルでのソートが可能だ。

動作確認: https://ai-japan-index.com/ai-job-impact/


手順

Step 1: データ収集と3軸スコアの準備

野村総研研究・job tag・総務省労働力調査から、70職種それぞれの3軸スコアを準備する。

「このコードの意味」: 3つの異なるデータソースを同じ「0〜100のスコア」スケールに変換してから統合する。単位が違うデータ(%、人数、タスク比率)をそのまま混ぜると、単位のレンジが大きい指標が結果を支配してしまう。

// 生成AI普及(2023年〜)の影響を反映した補正係数
const GEN_AI_CORRECTION = {
  "white-collar-routine":  1.15,  // 事務・経理等(定型作業が中心)
  "it-engineer":           1.00,  // IT・エンジニア(自動化と高度化が相殺)
  "person-to-person":      0.90,  // 医療・福祉・教育(対人主体)
  "creative-professional": 0.95,  // デザイン・マーケ(一部自動化)
  "physical-manual":       0.85   // 建設・製造(身体作業)
};

// NRI研究の代替確率に補正係数を適用
function adjustForGenAI(nriRate, category) {
  const factor = GEN_AI_CORRECTION[category] ?? 1.0;
  return Math.min(100, Math.round(nriRate * factor));
}

// AI Impact Score算出(3軸の重み付き加算)
function calcImpactScore(aiReplacement, coexistenceIndex, demandChange) {
  // 共存指数・需要変動は「高いほど良い」→ 100から引いて方向統一
  return Math.round(
    aiReplacement            * 0.40 +
    (100 - coexistenceIndex) * 0.30 +
    (100 - demandChange)     * 0.30
  );
}

つまずきポイント: NRI研究(2015年)は生成AI登場前のデータ。全職種に同じ係数(×1.0)を掛けると「変化なし」になってしまい実態を反映しない。カテゴリ別の補正係数を設けて生成AI普及の影響を反映すること。


Step 2: forceSimulationによるバブル配置計算

「このコードの意味」: 70職種を単純にXY座標にプロットすると、近いスコアの職種のバブルが重なって読めなくなる。D3のforceSimulationで「バブル同士が重ならない」制約を追加しつつ、各職種の本来の座標に近い位置を求める。

function calcBubblePositions(data, xScale, yScale, rScale) {
  // simulation.stop()で自動アニメーションをオフ(描画には結果だけ使う)
  const simulation = d3.forceSimulation(data)
    .force("x", d3.forceX(d => xScale(d.scores.aiReplacement)).strength(0.8))
    .force("y", d3.forceY(d => yScale(d.scores.coexistenceIndex)).strength(0.8))
    .force("collision", d3.forceCollide(d => rScale(d.employeeCount) + 2))
    .stop();

  // 事前に120回計算して安定した配置を求める
  // ループ数が少ないとバブルが重なったまま、多すぎると処理が重い
  for (let i = 0; i < 120; i++) {
    simulation.tick();
  }

  // この時点でdata[i].x, data[i].yがforceが計算した座標に更新されている
  return data;
}

NG例 → OK例:

// NG: forceSimulationをそのまま実行するとバブルが揺れながら収束するアニメーションが出る
const simulation = d3.forceSimulation(data)
  .force("x", ...)
  .force("y", ...)
  .force("collision", ...)
  .on("tick", () => {
    // これをやるとページ読み込み時に全バブルがカタカタ動く
    svg.selectAll("circle").attr("cx", d => d.x).attr("cy", d => d.y);
  });

// OK: stop() → 事前計算 → 結果で一回描画
const simulation = d3.forceSimulation(data)
  .force("x", ...)
  .force("collision", ...)
  .stop();
for (let i = 0; i < 120; i++) simulation.tick();
// 安定した座標で一度だけ描画
svg.selectAll("circle").attr("cx", d => d.x).attr("cy", d => d.y);

Step 3: バブル描画とカテゴリフィルタリング

「このコードの意味」: バブルを描画し、カテゴリ絞り込み時は「位置はそのまま、透過度だけを変える」方式にする。位置を動かすとデータの全体像(全職種の中での位置関係)が失われるため。

function renderChart(data, containerEl) {
  const width = containerEl.clientWidth;
  const mobile = width < 500;
  const maxR = mobile ? 16 : 26;

  const xScale = d3.scaleLinear().domain([0, 100]).range([maxR, width - maxR - 60]);
  const yScale = d3.scaleLinear().domain([0, 100]).range([300 - maxR, maxR]);
  const rScale = d3.scaleSqrt()
    .domain([0, d3.max(data, d => d.employeeCount)])
    .range([mobile ? 5 : 7, maxR]);
  const colorScale = d3.scaleLinear()
    .domain([20, 50, 80])
    .range(["#2ecc71", "#f39c12", "#e74c3c"]);

  const posData = calcBubblePositions(data, xScale, yScale, rScale);

  const svg = d3.select(containerEl).append("svg")
    .attr("width", width)
    .attr("height", 340);
  const g = svg.append("g").attr("transform", "translate(50, 10)");

  g.selectAll("circle.jb")
    .data(posData)
    .join("circle")
    .attr("class", d => `jb cat-${d.categoryId}`)
    .attr("cx", d => d.x)
    .attr("cy", d => d.y)
    .attr("r", d => rScale(d.employeeCount))
    .attr("fill", d => colorScale(d.scores.impactScore))
    .attr("fill-opacity", 0.75)
    .attr("stroke", "#ccc")
    .attr("stroke-width", 0.8)
    .on("touchstart", function(event, d) {
      event.preventDefault();
      event.stopPropagation();  // 必須: 省略するとパネルが開いて即閉じる
      showMobilePanel(d);
    })
    .on("click", function(event, d) {
      if (!mobile) showDetailPanel(d);
    });
}

// カテゴリ絞り込み: 位置固定・透過度のみ変更
function filterJobs(categoryId) {
  d3.selectAll("circle.jb")
    .transition().duration(300)
    .attr("opacity", function() {
      const isAll = categoryId === "all";
      const classes = this.className.baseVal;
      const isMatch = classes.includes(`cat-${categoryId}`);
      // &&はWordPressが変換するため三項演算子で回避
      return isAll ? 1 : (isMatch ? 1 : 0.1);
    });
}

Step 4: インクリメンタルサーチのデバウンス実装

「このコードの意味」: 検索ボックスに入力するたびにDOM操作すると重くなるため、50msのデバウンスを挟んで入力が止まってから検索実行する。

let searchTimer = null;

document.getElementById("job-search").addEventListener("input", function(e) {
  clearTimeout(searchTimer);
  const query = e.target.value.trim();
  searchTimer = setTimeout(function() {
    if (query.length === 0) {
      filterJobs("all");
      return;
    }
    d3.selectAll("circle.jb")
      .transition().duration(200)
      .attr("opacity", function(d) {
        const matched = d.name.includes(query);
        return matched ? 1 : 0.1;
      });
  }, 50);
});

Step N: 動作確認

  1. 70職種全てのバブルが描画されているか確認(DevToolsのElements でcircle要素が70個あるか確認)
  2. forceSimulationの配置でバブルが重なっていないか確認
  3. カテゴリタブ切り替えで透過度が正しく変化するか確認
  4. 職種名検索で該当職種が強調表示されるか確認
  5. スマホサイズでY軸ラベルが非表示・タップで詳細パネルが出るか確認

つまずきポイントまとめ

  • forceSimulationで「カタカタ動くバブル」: on("tick", updateFn) でリアルタイム更新をしているのが原因。stop() → ループ事前計算 → 結果で1回描画の方式に変更で解決
  • &&&#038;&#038; に変換されてJSエラー: WordPressのwpautopフィルターが変換する。全ての&&を三項演算子かネストifに書き直す。見落としやすいため、完成後にソース全体を grep で確認する
  • CDN <script src> がSWELLに無視される: typeof d3 !== "undefined" チェック → createElement('script') の動的ロードパターンに変更
  • タップで「パネルが開いて即閉じる」: touchstart ハンドラに e.stopPropagation() がないとdocumentのイベントハンドラが「パネル外タップ」と誤判定する。e.preventDefault()e.stopPropagation() をセットで必ず書く
  • NRI研究の職種名と現代の職種名のずれ: NRI研究(2015年)の「電子計算機オペレーター」等は現代では使われない名称。job tagの現代的な職種名とのマッピングを手動で作成する必要がある
  • forceSimulationのtick回数が足りない: 50回だとバブルが重なったまま収束することがある。70バブルでは120〜150回が安全。毎回描画して確認する

まとめ

今回学んだこと:

  • 複数データソースの統合は「全指標を0〜100に正規化してから加算」が基本手順
  • forceSimulationは「stop() → 事前計算 → 結果で描画」でアニメーションなしの安定した配置が得られる
  • WordPress/SWELLでは&&禁止・CDN動的ロードの2点を必ず確認する
  • touchstart には必ず e.stopPropagation() をセットで書く
  • 歴史的な研究データ(2015年)を使う場合は補正係数と注記で透明性を確保する

関連ツール一覧: AI Japan Index — 日本のAI関連データを無料で可視化

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?