// APIベースURL
const API_BASE = '/api';
// SVG要素とサイズ
const svg = d3.select('#diagram');
const width = window.innerWidth;
const height = window.innerHeight;
svg.attr('width', width).attr('height', height);
// 矢印マーカーの定義
svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 10)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('class', 'arrow');
// テキストサイズ計算用の一時的なテキスト要素
const textMeasure = svg.append('text').attr('visibility', 'hidden');
// グラフコンテナ(ズーム用)
const g = svg.append('g');
// 現在のズーム変換
let currentTransform = d3.zoomIdentity;
// ズーム機能
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
currentTransform = event.transform;
g.attr('transform', currentTransform);
updateNodeDisplay();
});
svg.call(zoom);
// 異なるグループに属するノード間の斥力(ルール3)
function groupRepulsion() {
let nodes;
function force(alpha) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
// 異なるグループに属するノード同士のみ反発
if (a.group && b.group && a.group !== b.group) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 距離減衰:遠いと影響なし、近いと強く反発
const maxDistance = 400; // この距離以上離れていれば影響なし
if (distance < maxDistance) {
const strength = (1 - distance / maxDistance) * 500 * alpha;
const fx = (dx / distance) * strength;
const fy = (dy / distance) * strength;
a.vx -= fx;
a.vy -= fy;
b.vx += fx;
b.vy += fy;
}
}
}
}
}
force.initialize = _ => nodes = _;
return force;
}
// 連結成分(tree)を識別
function identifyConnectedComponents(nodes, links) {
const adjacency = {};
const componentId = {};
// 隣接リストを構築(無向グラフとして扱う)
nodes.forEach(n => {
adjacency[n.id] = [];
});
links.forEach(link => {
const sourceId = link.source.id || link.source;
const targetId = link.target.id || link.target;
adjacency[sourceId].push(targetId);
adjacency[targetId].push(sourceId);
});
let currentComponentId = 0;
// BFSで各連結成分を識別
nodes.forEach(startNode => {
if (componentId[startNode.id] !== undefined) return;
const queue = [startNode.id];
componentId[startNode.id] = currentComponentId;
while (queue.length > 0) {
const current = queue.shift();
(adjacency[current] || []).forEach(neighbor => {
if (componentId[neighbor] === undefined) {
componentId[neighbor] = currentComponentId;
queue.push(neighbor);
}
});
}
currentComponentId++;
});
return componentId;
}
// BFSで各ノード間の最短経路距離を計算(forestに対応)
function computeShortestPaths(nodes, links) {
const distances = {};
const adjacency = {};
// 隣接リストを構築(無向グラフとして扱う)
nodes.forEach(n => {
adjacency[n.id] = [];
});
links.forEach(link => {
const sourceId = link.source.id || link.source;
const targetId = link.target.id || link.target;
adjacency[sourceId].push(targetId);
adjacency[targetId].push(sourceId);
});
// 各ノードから他の全ノードへのBFS
nodes.forEach(startNode => {
const dist = {};
const queue = [startNode.id];
dist[startNode.id] = 0;
while (queue.length > 0) {
const current = queue.shift();
const currentDist = dist[current];
(adjacency[current] || []).forEach(neighbor => {
if (dist[neighbor] === undefined) {
dist[neighbor] = currentDist + 1;
queue.push(neighbor);
}
});
}
distances[startNode.id] = dist;
});
return distances;
}
// 異なる連結成分(tree)間の斥力
function componentRepulsion() {
let nodes;
let componentId = {};
function force(alpha) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
// 異なる連結成分に属するノード同士のみ反発
if (componentId[a.id] !== undefined &&
componentId[b.id] !== undefined &&
componentId[a.id] !== componentId[b.id]) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 距離減衰:遠いと影響なし、近いと反発
const maxDistance = 300; // この距離以上離れていれば影響なし
if (distance < maxDistance) {
const strength = (1 - distance / maxDistance) * 200 * alpha;
const fx = (dx / distance) * strength;
const fy = (dy / distance) * strength;
a.vx -= fx;
a.vy -= fy;
b.vx += fx;
b.vy += fy;
}
}
}
}
}
force.initialize = (_nodes) => {
nodes = _nodes;
// linksは別途設定される
};
force.links = (_links) => {
const links = _links || [];
if (nodes && nodes.length > 0) {
componentId = identifyConnectedComponents(nodes, links);
}
return force;
};
return force;
}
// エッジ距離に基づく引力(グループより弱い)
function edgeDistanceAttraction() {
let nodes;
let links = [];
let distances = {};
function force(alpha) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
// 最短経路距離を取得
const dist = distances[a.id] && distances[a.id][b.id];
// 到達不可能な場合は何もしない
if (dist === undefined || dist === 0) continue;
// 距離に応じた引力の強さを計算
let strength = 0;
if (dist === 1) {
strength = 0.05; // 直接接続: 中程度の引力
} else if (dist === 2) {
strength = 0.02; // 2エッジ離れている: 弱い引力
} else if (dist === 3) {
strength = 0.01; // 3エッジ離れている: 非常に弱い引力
}
// 4エッジ以上は引力なし
if (strength > 0) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = strength * alpha;
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
}
}
force.initialize = (_nodes) => {
nodes = _nodes;
// linksは別途設定される
};
force.links = (_links) => {
links = _links || [];
if (nodes && nodes.length > 0) {
distances = computeShortestPaths(nodes, links);
}
return force;
};
return force;
}
// Force simulation
const simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(250))
.force('charge', d3.forceManyBody().strength(-600))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => Math.max(d.width || 100, d.height || 40) / 2 + 10)) // ルール2
.force('groupRepulsion', groupRepulsion()) // ルール3: 異なるグループ間の斥力
.force('componentRepulsion', componentRepulsion()) // 異なるtree間の斥力
.force('edgeDistance', edgeDistanceAttraction()); // エッジ距離による引力
// グラフデータ
let graphData = {
nodes: [],
links: [],
groups: []
};
// 現在のグループ中心点(動的に更新される)
let currentGroupCenters = {};
// 前回のデータのハッシュ(変更検出用)
let lastDataHash = '';
// データのハッシュを計算
function computeDataHash(data) {
// ノードID・ラベル・色・コード関連・グループとエッジとグループの組み合わせから簡易ハッシュを生成
const nodeIds = data.nodes.map(n => `${n.id}:${n.label}:${n.color || ''}:${n.solution || ''}:${n.filePath || ''}:${n.lineNumber || ''}:${n.code || ''}:${n.group || ''}`).sort().join(',');
const edgeIds = data.edges.map(e => `${e.from}-${e.to}-${e.label || ''}`).sort().join(',');
const groupIds = (data.groups || []).map(g => `${g.id}:${g.label}:${g.color || ''}`).sort().join(',');
return `${nodeIds}|${edgeIds}|${groupIds}`;
}
// グループ背景要素(最背面)
let groupBackground = g.append('g')
.attr('class', 'groups')
.selectAll('rect');
// リンク要素
let link = g.append('g')
.attr('class', 'links')
.selectAll('line');
// リンクラベル要素
let linkLabel = g.append('g')
.attr('class', 'link-labels')
.selectAll('text');
// ノード要素
let node = g.append('g')
.attr('class', 'nodes')
.selectAll('g');
// ダイアグラムデータの取得
async function fetchDiagram() {
try {
const response = await fetch(`${API_BASE}/diagram`);
const data = await response.json();
// データが変更されたかチェック
const newHash = computeDataHash(data);
if (newHash !== lastDataHash) {
lastDataHash = newHash;
updateGraph(data);
}
} catch (error) {
console.error('Failed to fetch diagram:', error);
}
}
// グラフの更新
function updateGraph(data) {
// データが空の場合
if (!data.nodes || data.nodes.length === 0) {
graphData = { nodes: [], links: [], groups: [] };
updateD3Elements();
return;
}
// グループデータの準備
graphData.groups = data.groups || [];
// ノードデータの準備(既存のノードの位置を保持)
const nodeMap = new Map(graphData.nodes.map(n => [n.id, n]));
graphData.nodes = data.nodes.map(n => {
const existing = nodeMap.get(n.id);
if (existing) {
// 既存のノードは位置を保持
return { ...n, x: existing.x, y: existing.y, vx: existing.vx, vy: existing.vy };
} else {
// 新しいノードは指定された座標、または中心付近に配置
return {
...n,
x: n.x !== undefined ? n.x : width / 2 + (Math.random() - 0.5) * 100,
y: n.y !== undefined ? n.y : height / 2 + (Math.random() - 0.5) * 100
};
}
});
// リンクデータの準備
graphData.links = data.edges.map(e => ({
source: e.from,
target: e.to,
label: e.label
}));
updateD3Elements();
}
// グループの中心点を計算(静的な円形配置)
function computeGroupCenters() {
const groupCenters = {};
const groupCount = graphData.groups.length;
if (groupCount === 0) {
return {};
}
// グループを円形に配置
const radius = Math.min(width, height) * 0.3;
graphData.groups.forEach((group, i) => {
const angle = (i / groupCount) * 2 * Math.PI;
groupCenters[group.id] = {
x: width / 2 + radius * Math.cos(angle),
y: height / 2 + radius * Math.sin(angle),
label: group.label,
color: group.color
};
});
return groupCenters;
}
// D3要素の更新
function updateD3Elements() {
// グループの中心点を初期計算
currentGroupCenters = computeGroupCenters();
// グループ背景の更新
groupBackground = groupBackground.data(graphData.groups, d => d.id);
groupBackground.exit().remove();
const groupBackgroundEnter = groupBackground.enter().append('g')
.attr('class', 'group');
groupBackgroundEnter.append('rect')
.attr('rx', 20)
.attr('ry', 20)
.style('fill', d => d.color || '#f3f4f6')
.style('stroke', '#d1d5db')
.style('stroke-width', 2)
.style('opacity', 0.3);
groupBackgroundEnter.append('text')
.attr('class', 'group-label')
.style('font-size', '16px')
.style('font-weight', 'bold')
.style('fill', '#6b7280')
.style('text-anchor', 'middle')
.style('pointer-events', 'none')
.text(d => d.label);
groupBackground = groupBackgroundEnter.merge(groupBackground);
// リンクの更新
link = link.data(graphData.links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
link.exit().remove();
const linkEnter = link.enter().append('line')
.attr('class', 'link')
.attr('marker-end', 'url(#arrowhead)');
link = linkEnter.merge(link);
// リンクラベルの更新
linkLabel = linkLabel.data(graphData.links.filter(d => d.label), d => `${d.source.id || d.source}-${d.target.id || d.target}`);
linkLabel.exit().remove();
const linkLabelEnter = linkLabel.enter().append('text')
.attr('class', 'link-label')
.text(d => d.label);
linkLabel = linkLabelEnter.merge(linkLabel);
// ノードの更新
node = node.data(graphData.nodes, d => d.id);
node.exit().remove();
const nodeEnter = node.enter().append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', dragStarted)
.on('drag', dragged)
.on('end', dragEnded));
// 各ノードに対して矩形とテキストを追加
nodeEnter.each(function(d) {
const nodeGroup = d3.select(this);
// テキストの幅を計算
textMeasure.text(d.label).style('font-size', '14px').style('font-weight', 'bold');
const textWidth = textMeasure.node().getBBox().width;
// 矩形のサイズを計算(パディングを追加)
const padding = 20;
let rectWidth = textWidth + padding * 2;
let rectHeight = 40;
// コードがある場合はサイズを大きくする
if (d.code && d.code.trim().length > 0) {
const lines = d.code.split('\n');
const maxLineLength = Math.max(...lines.map(line => line.length));
rectWidth = Math.max(rectWidth, maxLineLength * 7 + padding * 2);
rectHeight = Math.max(rectHeight, lines.length * 16 + 40);
}
// ノードのサイズを保存(衝突検出用)
d.width = rectWidth;
d.height = rectHeight;
// 角丸矩形を追加(中心が(0,0)になるように配置)
nodeGroup.append('rect')
.attr('width', rectWidth)
.attr('height', rectHeight)
.attr('x', -rectWidth / 2)
.attr('y', -rectHeight / 2)
.attr('rx', 8)
.attr('ry', 8)
.style('fill', d.color || '#667eea');
// ノードのラベル(中央に配置)
nodeGroup.append('text')
.attr('class', 'node-label')
.text(d.label);
// ノードのID(下に表示)
nodeGroup.append('text')
.attr('class', 'node-id')
.attr('dy', rectHeight / 2 + 15)
.text(d.id);
// コード表示用のコンテナ(foreignObject使用)
if (d.code && d.code.trim().length > 0) {
const codeContainer = nodeGroup.append('foreignObject')
.attr('class', 'code-container')
.attr('width', rectWidth)
.attr('height', rectHeight)
.attr('x', -rectWidth / 2)
.attr('y', -rectHeight / 2)
.style('display', 'none'); // 初期状態は非表示
const div = codeContainer.append('xhtml:div')
.style('width', '100%')
.style('height', '100%')
.style('padding', '10px')
.style('box-sizing', 'border-box')
.style('overflow', 'auto')
.style('font-family', 'monospace')
.style('font-size', '12px')
.style('color', '#fff')
.style('background', 'rgba(0,0,0,0.2)');
// JetBrains URLリンク
const url = generateJetBrainsUrl(d.solution, d.filePath, d.lineNumber);
if (url) {
div.append('xhtml:div')
.style('margin-bottom', '5px')
.style('font-size', '11px')
.style('color', '#a0a0a0')
.html(`<a href="${url}" style="color: #4fc3f7; text-decoration: none;">${d.filePath}:${d.lineNumber || 1}</a>`);
}
// コード表示
div.append('xhtml:pre')
.style('margin', '0')
.style('white-space', 'pre')
.style('font-size', '11px')
.style('line-height', '1.4')
.text(d.code);
}
});
node = nodeEnter.merge(node);
// 既存のノードの色も更新
node.select('rect')
.style('fill', d => d.color || '#667eea');
// Simulationの更新
simulation.nodes(graphData.nodes);
simulation.force('link').links(graphData.links);
// 連結成分斥力forceのリンク設定
const componentRepulsionForce = simulation.force('componentRepulsion');
if (componentRepulsionForce && componentRepulsionForce.links) {
componentRepulsionForce.links(graphData.links);
}
// エッジ距離forceのリンク設定
const edgeDistanceForce = simulation.force('edgeDistance');
if (edgeDistanceForce && edgeDistanceForce.links) {
edgeDistanceForce.links(graphData.links);
}
// グループクラスタリングの設定
if (Object.keys(currentGroupCenters).length > 0) {
simulation
.force('x', d3.forceX(d => {
if (d.group && currentGroupCenters[d.group]) {
return currentGroupCenters[d.group].x;
}
return width / 2;
}).strength(d => {
// ルール1: 同じグループのノードは中心に向かう
if (d.group && currentGroupCenters[d.group]) {
return 0.2;
}
return 0.0;
}))
.force('y', d3.forceY(d => {
if (d.group && currentGroupCenters[d.group]) {
return currentGroupCenters[d.group].y;
}
return height / 2;
}).strength(d => {
// ルール1: 同じグループのノードは中心に向かう
if (d.group && currentGroupCenters[d.group]) {
return 0.2;
}
return 0.0;
}));
} else {
// グループがない場合は中央に集める
simulation
.force('x', d3.forceX(width / 2).strength(0.05))
.force('y', d3.forceY(height / 2).strength(0.05));
}
simulation.alpha(0.3).restart();
}
// 矩形の端との交点を計算する関数
function getRectEdgePoint(centerX, centerY, width, height, targetX, targetY) {
const dx = targetX - centerX;
const dy = targetY - centerY;
if (dx === 0 && dy === 0) {
return { x: centerX, y: centerY };
}
const halfWidth = width / 2;
const halfHeight = height / 2;
// 角度を計算
const angle = Math.atan2(dy, dx);
// 矩形の辺との交点を計算
const tanAngle = Math.tan(angle);
const absTanAngle = Math.abs(tanAngle);
let x, y;
// 左右の辺と交差するか、上下の辺と交差するかを判定
if (absTanAngle <= halfHeight / halfWidth) {
// 左右の辺と交差
x = dx > 0 ? halfWidth : -halfWidth;
y = x * tanAngle;
} else {
// 上下の辺と交差
y = dy > 0 ? halfHeight : -halfHeight;
x = y / tanAngle;
}
return {
x: centerX + x,
y: centerY + y
};
}
// グループ背景の更新
function updateGroupBackgrounds() {
groupBackground.each(function(groupData) {
const groupNodes = graphData.nodes.filter(n => n.group === groupData.id);
if (groupNodes.length === 0) return;
// グループ内のノードの範囲を計算(余裕を持たせる)
const padding = 60;
const xs = groupNodes.map(n => n.x);
const ys = groupNodes.map(n => n.y);
const minX = Math.min(...xs) - padding;
const maxX = Math.max(...xs) + padding;
const minY = Math.min(...ys) - padding;
const maxY = Math.max(...ys) + padding;
const groupElement = d3.select(this);
groupElement.select('rect')
.attr('x', minX)
.attr('y', minY)
.attr('width', maxX - minX)
.attr('height', maxY - minY);
groupElement.select('text')
.attr('x', (minX + maxX) / 2)
.attr('y', minY - 10);
});
}
// Tick関数(アニメーション)
simulation.on('tick', () => {
// グループ背景を更新
updateGroupBackgrounds();
link.attr('x1', d => {
const sourceWidth = d.source.width || 100;
const sourceHeight = d.source.height || 40;
const point = getRectEdgePoint(
d.source.x, d.source.y,
sourceWidth, sourceHeight,
d.target.x, d.target.y
);
return point.x;
})
.attr('y1', d => {
const sourceWidth = d.source.width || 100;
const sourceHeight = d.source.height || 40;
const point = getRectEdgePoint(
d.source.x, d.source.y,
sourceWidth, sourceHeight,
d.target.x, d.target.y
);
return point.y;
})
.attr('x2', d => {
const targetWidth = d.target.width || 100;
const targetHeight = d.target.height || 40;
const point = getRectEdgePoint(
d.target.x, d.target.y,
targetWidth, targetHeight,
d.source.x, d.source.y
);
return point.x;
})
.attr('y2', d => {
const targetWidth = d.target.width || 100;
const targetHeight = d.target.height || 40;
const point = getRectEdgePoint(
d.target.x, d.target.y,
targetWidth, targetHeight,
d.source.x, d.source.y
);
return point.y;
});
linkLabel
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
// ドラッグハンドラ
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
// ウィンドウリサイズ対応
window.addEventListener('resize', () => {
const newWidth = window.innerWidth;
const newHeight = window.innerHeight;
svg.attr('width', newWidth).attr('height', newHeight);
simulation.force('center', d3.forceCenter(newWidth / 2, newHeight / 2));
simulation.alpha(0.3).restart();
});
// JetBrains URLスキームを生成
function generateJetBrainsUrl(solution, filePath, lineNumber) {
if (!solution || !filePath) return null;
const line = lineNumber || 1;
return `jetbrains://rider/navigate/reference?project=${encodeURIComponent(solution)}&path=${encodeURIComponent(filePath)}&line=${line}`;
}
// ノード表示の更新(ズームレベルに応じて)
function updateNodeDisplay() {
const zoomThreshold = 1.5; // この倍率以上でコード表示に切り替え
const showCode = currentTransform.k >= zoomThreshold;
node.each(function(d) {
const nodeGroup = d3.select(this);
const hasCode = d.code && d.code.trim().length > 0;
// ラベル表示の切り替え
nodeGroup.selectAll('.node-label, .node-id')
.style('display', (showCode && hasCode) ? 'none' : null);
// コード表示の切り替え
nodeGroup.selectAll('.code-container')
.style('display', (showCode && hasCode) ? null : 'none');
});
}
// 定期的に更新
setInterval(fetchDiagram, 2000);
// 初期ロード
fetchDiagram();