この記事でできること
- 異なる複数の公開統計データを組み合わせて職種別スコアを算出する手順を理解できる
- 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次元表現。カテゴリタブで絞り込み、職種名での検索、下部テーブルでのソートが可能だ。
手順
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: 動作確認
- 70職種全てのバブルが描画されているか確認(DevToolsのElements でcircle要素が70個あるか確認)
- forceSimulationの配置でバブルが重なっていないか確認
- カテゴリタブ切り替えで透過度が正しく変化するか確認
- 職種名検索で該当職種が強調表示されるか確認
- スマホサイズでY軸ラベルが非表示・タップで詳細パネルが出るか確認
つまずきポイントまとめ
-
forceSimulationで「カタカタ動くバブル」:
on("tick", updateFn)でリアルタイム更新をしているのが原因。stop()→ ループ事前計算 → 結果で1回描画の方式に変更で解決 -
&&が&&に変換されて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関連データを無料で可視化
