Phase 5: Enhance Neo4j graph visualization

- Path visualization optimization:
  - Add flowing dash animation to show path direction
  - Add glow effects to start/end nodes
  - Add path info panel with length and node count stats
  - Add gradient color connections

- Community visualization enhancement:
  - Add focus mode: click community to highlight
  - Dim non-focused communities
  - Add intra-community links to show internal relationships
  - Show density stats in community list

- Neighbor query visualization:
  - Add glow effect to center node
  - Add fade-in effects to connections

- Update STATUS.md to mark Phase 5 Neo4j integration complete
This commit is contained in:
OpenClaw Bot
2026-02-21 00:14:47 +08:00
parent 9e7f68ece7
commit f38e060fa7
3 changed files with 393 additions and 431 deletions

View File

@@ -2042,389 +2042,7 @@ document.head.appendChild(style);
// ==================== Graph Analysis Functions ====================
// Load graph statistics
window.loadGraphStats = async function() {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/stats`);
if (!res.ok) throw new Error('Failed to load graph stats');
const stats = await res.json();
document.getElementById('statNodeCount').textContent = stats.node_count || 0;
document.getElementById('statEdgeCount').textContent = stats.edge_count || 0;
document.getElementById('statDensity').textContent = (stats.density || 0).toFixed(3);
document.getElementById('statComponents').textContent = stats.connected_components || 0;
} catch (err) {
console.error('Load graph stats failed:', err);
}
};
// Sync project to Neo4j
window.syncToNeo4j = async function() {
if (!currentProject) return;
try {
showNotification('正在同步到 Neo4j...', 'info');
const res = await fetch(`${API_BASE}/neo4j/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: currentProject.id })
});
if (!res.ok) throw new Error('Sync failed');
const result = await res.json();
showNotification(`同步完成: ${result.nodes_synced} 节点, ${result.relationships_synced} 关系`, 'success');
// Refresh stats after sync
await loadGraphStats();
} catch (err) {
console.error('Sync to Neo4j failed:', err);
alert('同步失败: ' + err.message);
}
};
// Load centrality analysis
window.loadCentralityAnalysis = async function() {
if (!currentProject) return;
const metric = document.getElementById('centralityMetric')?.value || 'degree';
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/centrality?metric=${metric}`);
if (!res.ok) throw new Error('Failed to load centrality analysis');
const data = await res.json();
const list = document.getElementById('centralityList');
if (!data.rankings || data.rankings.length === 0) {
list.innerHTML = '<p style="color:#666;text-align:center;padding:20px;">暂无数据</p>';
return;
}
list.innerHTML = data.rankings.slice(0, 10).map((r, i) => `
<div class="centrality-item" onclick="highlightEntityInGraph('${r.entity_id}')">
<div class="centrality-rank">${i + 1}</div>
<div class="centrality-info">
<div class="centrality-name">${r.entity_name}</div>
<div class="centrality-score">得分: ${r.score.toFixed(3)}</div>
</div>
</div>
`).join('');
} catch (err) {
console.error('Load centrality analysis failed:', err);
document.getElementById('centralityList').innerHTML = '<p style="color:#666;text-align:center;padding:20px;">加载失败</p>';
}
};
// Load communities
window.loadCommunities = async function() {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/communities`);
if (!res.ok) throw new Error('Failed to load communities');
const data = await res.json();
const list = document.getElementById('communitiesList');
if (!data.communities || data.communities.length === 0) {
list.innerHTML = '<p style="color:#666;text-align:center;padding:20px;">未发现社区</p>';
return;
}
list.innerHTML = data.communities.map((c, i) => `
<div class="community-item">
<div class="community-header">
<span class="community-id">社区 ${i + 1}</span>
<span class="community-size">${c.size} 节点</span>
</div>
<div class="community-density">密度: ${(c.density || 0).toFixed(3)}</div>
<div class="community-nodes">
${c.nodes.slice(0, 5).map(n => `<span class="community-node">${n.name}</span>`).join('')}
${c.nodes.length > 5 ? `<span class="community-node">+${c.nodes.length - 5}</span>` : ''}
</div>
</div>
`).join('');
} catch (err) {
console.error('Load communities failed:', err);
document.getElementById('communitiesList').innerHTML = '<p style="color:#666;text-align:center;padding:20px;">加载失败</p>';
}
};
// Find shortest path
window.findShortestPath = async function() {
if (!currentProject) return;
const startId = document.getElementById('pathStartEntity')?.value;
const endId = document.getElementById('pathEndEntity')?.value;
if (!startId || !endId) {
alert('请选择起点和终点实体');
return;
}
if (startId === endId) {
alert('起点和终点不能相同');
return;
}
try {
const res = await fetch(`${API_BASE}/graph/shortest-path`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_id_1: startId,
entity_id_2: endId,
max_depth: 10
})
});
if (!res.ok) throw new Error('Failed to find path');
const data = await res.json();
const resultsDiv = document.getElementById('graphAnalysisResults');
if (!data.path || data.path.length === 0) {
resultsDiv.innerHTML = '<div class="path-result"><div class="path-header">未找到路径</div></div>';
resultsDiv.classList.add('show');
return;
}
// Build path display
const pathHtml = data.path.map((node, i) => {
const arrow = i < data.path.length - 1 ? '<span class="path-arrow">→</span>' : '';
return `<span class="path-node">${node.name}</span>${arrow}`;
}).join('');
resultsDiv.innerHTML = `
<div class="path-result">
<div class="path-header">最短路径 (${data.path.length} 个节点, 长度: ${data.path_length})</div>
<div class="path-nodes">${pathHtml}</div>
</div>
`;
resultsDiv.classList.add('show');
// Visualize path in graph
visualizePath(data.path);
} catch (err) {
console.error('Find shortest path failed:', err);
alert('查找路径失败: ' + err.message);
}
};
// Find neighbors
window.findNeighbors = async function() {
if (!currentProject) return;
const entityId = document.getElementById('neighborEntity')?.value;
if (!entityId) {
alert('请选择实体');
return;
}
try {
const res = await fetch(`${API_BASE}/entities/${entityId}/neighbors?limit=50`);
if (!res.ok) throw new Error('Failed to find neighbors');
const data = await res.json();
const resultsDiv = document.getElementById('graphAnalysisResults');
if (!data.neighbors || data.neighbors.length === 0) {
resultsDiv.innerHTML = '<div class="neighbor-result"><div class="neighbor-header">未找到邻居节点</div></div>';
resultsDiv.classList.add('show');
return;
}
const entityName = projectEntities.find(e => e.id === entityId)?.name || entityId;
resultsDiv.innerHTML = `
<div class="neighbor-result">
<div class="neighbor-header">${entityName} 的邻居节点 (${data.neighbors.length})</div>
<div class="neighbor-list">
${data.neighbors.map(n => `<span class="neighbor-item">${n.entity_name} (${n.relation_type})</span>`).join('')}
</div>
</div>
`;
resultsDiv.classList.add('show');
} catch (err) {
console.error('Find neighbors failed:', err);
alert('查找邻居失败: ' + err.message);
}
};
// Populate entity selects for graph analysis
function populateGraphEntitySelects() {
const selects = [
document.getElementById('pathStartEntity'),
document.getElementById('pathEndEntity'),
document.getElementById('neighborEntity')
];
selects.forEach(select => {
if (!select) return;
const currentValue = select.value;
select.innerHTML = '<option value="">选择实体</option>';
projectEntities.forEach(ent => {
const option = document.createElement('option');
option.value = ent.id;
option.textContent = ent.name;
select.appendChild(option);
});
if (currentValue) {
select.value = currentValue;
}
});
}
// Visualize path in graph
function visualizePath(path) {
const svg = d3.select('#graphAnalysisSvg');
svg.selectAll('*').remove();
if (!path || path.length === 0) return;
const width = svg.node().parentElement.clientWidth;
const height = svg.node().parentElement.clientHeight || 400;
svg.attr('width', width).attr('height', height);
// Create nodes and links
const nodes = path.map((n, i) => ({
id: n.id,
name: n.name,
type: n.type || 'OTHER',
index: i
}));
const links = [];
for (let i = 0; i < nodes.length - 1; i++) {
links.push({ source: nodes[i].id, target: nodes[i + 1].id });
}
// Color scale
const colorScale = {
'PROJECT': '#7b2cbf',
'TECH': '#00d4ff',
'PERSON': '#ff6b6b',
'ORG': '#4ecdc4',
'OTHER': '#666'
};
// Create force simulation
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2));
// Draw links
const link = svg.append('g')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', '#00d4ff')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrow)');
// Add arrow marker
svg.append('defs').append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#00d4ff');
// Draw nodes
const node = svg.append('g')
.selectAll('g')
.data(nodes)
.enter().append('g')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
node.append('circle')
.attr('r', 20)
.attr('fill', d => colorScale[d.type] || '#666')
.attr('stroke', '#fff')
.attr('stroke-width', 2);
node.append('text')
.attr('dy', 35)
.attr('text-anchor', 'middle')
.attr('fill', '#e0e0e0')
.attr('font-size', '12px')
.text(d => d.name);
// Update positions
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
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;
}
}
// Reset graph visualization
window.resetGraphViz = function() {
const svg = d3.select('#graphAnalysisSvg');
svg.selectAll('*').remove();
document.getElementById('graphAnalysisResults').classList.remove('show');
};
// Highlight entity in graph
function highlightEntityInGraph(entityId) {
// This would highlight the entity in the main graph view
// For now, just switch to workbench and select the entity
switchView('workbench');
setTimeout(() => selectEntity(entityId), 100);
}
// Initialize graph analysis view
function initGraphAnalysis() {
populateGraphEntitySelects();
loadGraphStats();
}
// ==================== Phase 5: Graph Analysis ====================
function initGraphAnalysis() {
if (!currentProject) return;
@@ -2596,6 +2214,7 @@ function renderCentrality(data) {
}).join('');
}
// Enhanced community visualization with better interactivity
function renderCommunities(data) {
const svg = d3.select('#communitiesSvg');
svg.selectAll('*').remove();
@@ -2610,12 +2229,14 @@ function renderCommunities(data) {
// 渲染社区列表
container.innerHTML = data.communities.map((community, idx) => {
const nodeNames = community.node_names || [];
const density = community.density ? community.density.toFixed(3) : 'N/A';
return `
<div class="community-item">
<div class="community-item" onclick="focusCommunity(${idx})" style="cursor:pointer;">
<div class="community-header">
<span class="community-name">社区 ${idx + 1}</span>
<span class="community-size">${community.size} 个节点</span>
</div>
<div style="font-size:0.75rem;color:#888;margin-bottom:6px;">密度: ${density}</div>
<div class="community-nodes">
${nodeNames.slice(0, 8).map(name => `
<span class="community-node-tag">${name}</span>
@@ -2630,7 +2251,19 @@ function renderCommunities(data) {
renderCommunitiesViz(data.communities);
}
function renderCommunitiesViz(communities) {
// Global variable to track focused community
let focusedCommunityIndex = null;
// Focus on a specific community
window.focusCommunity = function(communityIndex) {
focusedCommunityIndex = communityIndex;
if (communitiesData && communitiesData.communities) {
renderCommunitiesViz(communitiesData.communities, communityIndex);
}
};
// Enhanced community visualization with focus support
function renderCommunitiesViz(communities, focusIndex = null) {
const svg = d3.select('#communitiesSvg');
const container = svg.node().parentElement;
const width = container.clientWidth;
@@ -2646,52 +2279,122 @@ function renderCommunitiesViz(communities) {
// 准备节点数据
let allNodes = [];
let allLinks = [];
communities.forEach((comm, idx) => {
const isFocused = focusIndex === null || focusIndex === idx;
const isDimmed = focusIndex !== null && focusIndex !== idx;
const opacity = isDimmed ? 0.2 : 1;
const nodes = (comm.node_names || []).map((name, i) => ({
id: `${idx}-${i}`,
name: name,
community: idx,
color: colors[idx % colors.length]
color: colors[idx % colors.length],
opacity: opacity,
isFocused: isFocused
}));
// Create intra-community links
if (nodes.length > 1) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
allLinks.push({
source: nodes[i].id,
target: nodes[j].id,
community: idx,
opacity: opacity * 0.3
});
}
}
}
allNodes = allNodes.concat(nodes);
});
if (allNodes.length === 0) return;
// Create community centers for force layout
const communityCenters = communities.map((_, idx) => ({
x: width / 2 + (idx % 3 - 1) * width / 4,
y: height / 2 + Math.floor(idx / 3) * height / 4
}));
// 使用力导向布局
const simulation = d3.forceSimulation(allNodes)
.force('charge', d3.forceManyBody().strength(-100))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(30))
.force('x', d3.forceX(width / 2).strength(0.05))
.force('y', d3.forceY(height / 2).strength(0.05));
.force('charge', d3.forceManyBody().strength(d => d.isFocused ? -150 : -50))
.force('collision', d3.forceCollide().radius(d => d.isFocused ? 35 : 25))
.force('x', d3.forceX(d => communityCenters[d.community]?.x || width / 2).strength(0.1))
.force('y', d3.forceY(d => communityCenters[d.community]?.y || height / 2).strength(0.1))
.force('link', d3.forceLink(allLinks).id(d => d.id).distance(60).strength(0.1));
// 绘制节点
// Draw links
const link = svg.selectAll('.community-link')
.data(allLinks)
.enter().append('line')
.attr('class', 'community-link')
.attr('stroke', d => colors[d.community % colors.length])
.attr('stroke-width', 1)
.attr('stroke-opacity', d => d.opacity);
// Draw nodes
const node = svg.selectAll('.community-node')
.data(allNodes)
.enter().append('g')
.attr('class', 'community-node')
.style('cursor', 'pointer')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
// Node glow for focused community
node.filter(d => d.isFocused)
.append('circle')
.attr('r', 28)
.attr('fill', d => d.color)
.attr('opacity', 0.2)
.attr('filter', 'url(#glow)');
// Main node circle
node.append('circle')
.attr('r', 20)
.attr('r', d => d.isFocused ? 22 : 18)
.attr('fill', d => d.color)
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('opacity', 0.8);
.attr('stroke-width', d => d.isFocused ? 3 : 2)
.attr('opacity', d => d.opacity);
node.append('text')
.text(d => d.name.length > 4 ? d.name.slice(0, 3) + '...' : d.name)
// Node labels (only for focused community)
node.filter(d => d.isFocused)
.append('text')
.text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name)
.attr('text-anchor', 'middle')
.attr('dy', 5)
.attr('fill', '#fff')
.attr('dy', 35)
.attr('fill', '#e0e0e0')
.attr('font-size', '10px')
.attr('font-weight', '500')
.style('pointer-events', 'none');
// Community label for first node in each community
node.filter(d => {
const commNodes = allNodes.filter(n => n.community === d.community);
return d.id === commNodes[0]?.id && d.isFocused;
})
.append('text')
.attr('dy', -30)
.attr('text-anchor', 'middle')
.attr('fill', d => d.color)
.attr('font-size', '11px')
.attr('font-weight', '600')
.text(d => `社区 ${d.community + 1}`);
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
@@ -2732,6 +2435,7 @@ window.switchGraphTab = function(tabName) {
}
};
// Enhanced shortest path with better visualization
async function findShortestPath() {
const startId = document.getElementById('pathStartEntity').value;
const endId = document.getElementById('pathEndEntity').value;
@@ -2788,6 +2492,7 @@ async function findShortestPath() {
}
}
// Enhanced path rendering with animation and better styling
function renderPath(data) {
const startEntity = projectEntities.find(e => e.id === data.start_entity_id);
const endEntity = projectEntities.find(e => e.id === data.end_entity_id);
@@ -2816,24 +2521,79 @@ function renderPath(data) {
return;
}
// 准备节点和边
// Add defs for gradients and filters
const defs = svg.append('defs');
// Glow filter
const filter = defs.append('filter')
.attr('id', 'pathGlow')
.attr('x', '-50%')
.attr('y', '-50%')
.attr('width', '200%')
.attr('height', '200%');
filter.append('feGaussianBlur')
.attr('stdDeviation', '4')
.attr('result', 'coloredBlur');
const feMerge = filter.append('feMerge');
feMerge.append('feMergeNode').attr('in', 'coloredBlur');
feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
// Linear gradient for path
const gradient = defs.append('linearGradient')
.attr('id', 'pathLineGradient')
.attr('gradientUnits', 'userSpaceOnUse');
gradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff');
gradient.append('stop').attr('offset', '100%').attr('stop-color', '#7b2cbf');
// 准备节点和边 - use linear layout for clarity
const nodes = data.path.map((nodeId, idx) => ({
id: nodeId,
name: projectEntities.find(e => e.id === nodeId)?.name || nodeId,
type: projectEntities.find(e => e.id === nodeId)?.type || 'OTHER',
x: (width / (data.path.length + 1)) * (idx + 1),
y: height / 2
y: height / 2,
isStart: idx === 0,
isEnd: idx === data.path.length - 1,
isMiddle: idx > 0 && idx < data.path.length - 1
}));
const links = [];
for (let i = 0; i < nodes.length - 1; i++) {
links.push({
source: nodes[i],
target: nodes[i + 1]
target: nodes[i + 1],
index: i
});
}
// 绘制连线
svg.selectAll('.path-link')
// Color scale
const colorScale = {
'PROJECT': '#7b2cbf',
'TECH': '#00d4ff',
'PERSON': '#ff6b6b',
'ORG': '#4ecdc4',
'OTHER': '#666'
};
// Draw glow lines first (behind)
svg.selectAll('.path-link-glow')
.data(links)
.enter().append('line')
.attr('class', 'path-link-glow')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
.attr('stroke', '#00d4ff')
.attr('stroke-width', 8)
.attr('stroke-opacity', 0.2)
.attr('filter', 'url(#pathGlow)');
// Draw main lines
const linkLines = svg.selectAll('.path-link')
.data(links)
.enter().append('line')
.attr('class', 'path-link')
@@ -2841,16 +2601,40 @@ function renderPath(data) {
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
.attr('stroke', '#00d4ff')
.attr('stroke', 'url(#pathLineGradient)')
.attr('stroke-width', 3)
.attr('stroke-linecap', 'round');
// Animated dash line
const animLines = svg.selectAll('.path-link-anim')
.data(links)
.enter().append('line')
.attr('class', 'path-link-anim')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5,5')
.attr('stroke-opacity', 0.6);
// 绘制箭头
// Animate the dash offset
function animateDash() {
animLines.attr('stroke-dashoffset', function() {
const current = parseFloat(d3.select(this).attr('stroke-dashoffset') || 0);
return current - 0.5;
});
requestAnimationFrame(animateDash);
}
animateDash();
// Draw arrows
links.forEach((link, i) => {
const angle = Math.atan2(link.target.y - link.source.y, link.target.x - link.source.x);
const arrowSize = 10;
const arrowX = link.target.x - 25 * Math.cos(angle);
const arrowY = link.target.y - 25 * Math.sin(angle);
const arrowX = link.target.x - 30 * Math.cos(angle);
const arrowY = link.target.y - 30 * Math.sin(angle);
svg.append('polygon')
.attr('points', `0,-${arrowSize/2} ${arrowSize},0 0,${arrowSize/2}`)
@@ -2858,31 +2642,71 @@ function renderPath(data) {
.attr('fill', '#00d4ff');
});
// 绘制节点
// Draw nodes
const node = svg.selectAll('.path-node')
.data(nodes)
.enter().append('g')
.attr('class', 'path-node')
.attr('transform', d => `translate(${d.x},${d.y})`);
// Glow for start/end nodes
node.filter(d => d.isStart || d.isEnd)
.append('circle')
.attr('r', 35)
.attr('fill', d => d.isStart ? '#00d4ff' : '#7b2cbf')
.attr('opacity', 0.2)
.attr('filter', 'url(#pathGlow)');
// Main node circles
node.append('circle')
.attr('r', d => d.id === data.start_entity_id || d.id === data.end_entity_id ? 30 : 25)
.attr('r', d => d.isStart || d.isEnd ? 28 : 22)
.attr('fill', d => {
if (d.id === data.start_entity_id) return '#00d4ff';
if (d.id === data.end_entity_id) return '#7b2cbf';
return '#333';
if (d.isStart) return '#00d4ff';
if (d.isEnd) return '#7b2cbf';
return colorScale[d.type] || '#333';
})
.attr('stroke', '#fff')
.attr('stroke-width', 2);
.attr('stroke-width', d => d.isStart || d.isEnd ? 4 : 2);
node.append('text')
.text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name)
.attr('text-anchor', 'middle')
// Step numbers for middle nodes
node.filter(d => d.isMiddle)
.append('text')
.attr('dy', 5)
.attr('fill', d => (d.id === data.start_entity_id || d.id === data.end_entity_id) ? '#000' : '#fff')
.attr('font-size', '11px')
.attr('text-anchor', 'middle')
.attr('fill', '#fff')
.attr('font-size', '12px')
.attr('font-weight', '600')
.text(d => d.index);
// Node labels
node.append('text')
.text(d => d.name.length > 8 ? d.name.slice(0, 7) + '...' : d.name)
.attr('text-anchor', 'middle')
.attr('dy', d => d.isStart || d.isEnd ? 45 : 38)
.attr('fill', '#e0e0e0')
.attr('font-size', d => d.isStart || d.isEnd ? '13px' : '11px')
.attr('font-weight', d => d.isStart || d.isEnd ? '600' : '400')
.style('pointer-events', 'none');
// Start/End labels
node.filter(d => d.isStart)
.append('text')
.attr('dy', -40)
.attr('text-anchor', 'middle')
.attr('fill', '#00d4ff')
.attr('font-size', '11px')
.attr('font-weight', '600')
.text('起点');
node.filter(d => d.isEnd)
.append('text')
.attr('dy', -40)
.attr('text-anchor', 'middle')
.attr('fill', '#7b2cbf')
.attr('font-size', '11px')
.attr('font-weight', '600')
.text('终点');
// 渲染路径信息
renderPathInfo(data);
}
@@ -2890,7 +2714,23 @@ function renderPath(data) {
function renderPathInfo(data) {
const container = document.getElementById('pathInfo');
let html = '';
// Calculate path statistics
const pathLength = data.path.length;
const steps = pathLength - 1;
let html = `
<div style="background:#141414;border-radius:8px;padding:12px;margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:8px;">
<span style="color:#888;">路径长度</span>
<span style="color:#00d4ff;font-weight:600;">${steps} 步</span>
</div>
<div style="display:flex;justify-content:space-between;">
<span style="color:#888;">节点数</span>
<span style="color:#00d4ff;font-weight:600;">${pathLength} 个</span>
</div>
</div>
`;
data.path.forEach((nodeId, idx) => {
const entity = projectEntities.find(e => e.id === nodeId);
const isStart = idx === 0;
@@ -2898,7 +2738,7 @@ function renderPathInfo(data) {
html += `
<div class="path-step">
<div class="path-step-number">${idx + 1}</div>
<div class="path-step-number" style="background:${isStart ? '#00d4ff' : isEnd ? '#7b2cbf' : '#333'};color:${isStart || isEnd ? '#000' : '#fff'};">${idx + 1}</div>
<div class="path-step-content">
<div class="path-step-entity">${entity?.name || nodeId}</div>
${!isStart ? `<div class="path-step-relation">← 通过关系连接</div>` : ''}
@@ -2960,6 +2800,7 @@ async function findNeighbors() {
}
}
// Enhanced neighbors visualization
function renderNeighbors(data, centerEntity) {
const svg = d3.select('#pathSvg');
svg.selectAll('*').remove();
@@ -2982,6 +2823,23 @@ function renderNeighbors(data, centerEntity) {
return;
}
// Add glow filter
const defs = svg.append('defs');
const filter = defs.append('filter')
.attr('id', 'neighborGlow')
.attr('x', '-50%')
.attr('y', '-50%')
.attr('width', '200%')
.attr('height', '200%');
filter.append('feGaussianBlur')
.attr('stdDeviation', '3')
.attr('result', 'coloredBlur');
const feMerge = filter.append('feMerge');
feMerge.append('feMergeNode').attr('in', 'coloredBlur');
feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
// 中心节点
const centerNode = {
id: centerEntity.id,
@@ -3003,7 +2861,20 @@ function renderNeighbors(data, centerEntity) {
const allNodes = [centerNode, ...neighborNodes];
// 绘制连线
// Draw glow lines
neighborNodes.forEach(neighbor => {
svg.append('line')
.attr('x1', centerNode.x)
.attr('y1', centerNode.y)
.attr('x2', neighbor.x)
.attr('y2', neighbor.y)
.attr('stroke', '#00d4ff')
.attr('stroke-width', 6)
.attr('stroke-opacity', 0.1)
.attr('filter', 'url(#neighborGlow)');
});
// Draw main lines
neighborNodes.forEach(neighbor => {
svg.append('line')
.attr('x1', centerNode.x)
@@ -3015,29 +2886,58 @@ function renderNeighbors(data, centerEntity) {
.attr('stroke-opacity', 0.4);
});
// 绘制节点
// Draw nodes
const node = svg.selectAll('.neighbor-node')
.data(allNodes)
.enter().append('g')
.attr('class', 'neighbor-node')
.attr('transform', d => `translate(${d.x},${d.y})`);
// Glow for center node
node.filter(d => d.isCenter)
.append('circle')
.attr('r', 40)
.attr('fill', '#00d4ff')
.attr('opacity', 0.2)
.attr('filter', 'url(#neighborGlow)');
// Main node circles
node.append('circle')
.attr('r', d => d.isCenter ? 35 : 25)
.attr('fill', d => d.isCenter ? '#00d4ff' : '#333')
.attr('stroke', '#fff')
.attr('stroke-width', 2);
.attr('stroke-width', d => d.isCenter ? 4 : 2);
// Node labels
node.append('text')
.text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name)
.attr('text-anchor', 'middle')
.attr('dy', 5)
.attr('fill', d => d.isCenter ? '#000' : '#fff')
.attr('font-size', d => d.isCenter ? '12px' : '10px')
.attr('dy', d => d.isCenter ? 50 : 38)
.attr('fill', '#e0e0e0')
.attr('font-size', d => d.isCenter ? '13px' : '11px')
.attr('font-weight', d => d.isCenter ? '600' : '400')
.style('pointer-events', 'none');
// Center label
node.filter(d => d.isCenter)
.append('text')
.attr('dy', -45)
.attr('text-anchor', 'middle')
.attr('fill', '#00d4ff')
.attr('font-size', '11px')
.attr('font-weight', '600')
.text('中心');
// 渲染邻居信息
let html = `<p style="color:#888;margin-bottom:12px;">找到 ${neighbors.length} 个邻居节点:</p>`;
let html = `
<div style="background:#141414;border-radius:8px;padding:12px;margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;">
<span style="color:#888;">邻居节点数</span>
<span style="color:#00d4ff;font-weight:600;">${neighbors.length} 个</span>
</div>
</div>
`;
neighbors.forEach((n, idx) => {
html += `
<div class="path-step">
@@ -3080,3 +2980,22 @@ function showNotification(message, type = 'info') {
}, 300);
}, 3000);
}
// Reset graph visualization
window.resetGraphViz = function() {
const svg = d3.select('#graphAnalysisSvg');
svg.selectAll('*').remove();
document.getElementById('graphAnalysisResults').classList.remove('show');
focusedCommunityIndex = null;
if (communitiesData) {
renderCommunities(communitiesData);
}
};
// Highlight entity in graph
function highlightEntityInGraph(entityId) {
// This would highlight the entity in the main graph view
// For now, just switch to workbench and select the entity
switchView('workbench');
setTimeout(() => selectEntity(entityId), 100);
}