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:
16
STATUS.md
16
STATUS.md
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
Phase 5: 高级功能 - **进行中 🚧**
|
||||
Phase 5: 高级功能 - **已完成 ✅**
|
||||
|
||||
## 部署状态
|
||||
|
||||
@@ -119,7 +119,7 @@ Phase 5: 高级功能 - **进行中 🚧**
|
||||
|
||||
## 待完成
|
||||
|
||||
### Phase 5 - Neo4j 图数据库集成 (进行中 🚧)
|
||||
### Phase 5 - Neo4j 图数据库集成 (已完成 ✅)
|
||||
- [x] 创建 neo4j_manager.py - Neo4j 管理模块
|
||||
- 数据同步到 Neo4j(实体、关系、项目)
|
||||
- 批量同步支持
|
||||
@@ -154,8 +154,16 @@ Phase 5: 高级功能 - **进行中 🚧**
|
||||
- 邻居节点查询和可视化
|
||||
- Neo4j 连接状态指示
|
||||
- 数据同步按钮
|
||||
- [ ] 路径可视化优化
|
||||
- [ ] 社区可视化增强
|
||||
- [x] 路径可视化优化
|
||||
- 添加路径动画效果(流动虚线)
|
||||
- 路径节点和边的特殊样式(起点终点高亮)
|
||||
- 发光效果增强视觉层次
|
||||
- 路径信息面板(显示路径长度、节点数统计)
|
||||
- [x] 社区可视化增强
|
||||
- 社区发现结果的更好可视化(不同颜色区分社区)
|
||||
- 社区统计信息(每个社区的节点数、密度)
|
||||
- 点击社区可以聚焦显示该社区的子图
|
||||
- 社区内节点连线显示内部关联
|
||||
|
||||
### Phase 4 - Neo4j 集成 (可选)
|
||||
- [ ] 将图谱数据同步到 Neo4j
|
||||
|
||||
35
STATUS_update.md
Normal file
35
STATUS_update.md
Normal file
@@ -0,0 +1,35 @@
|
||||
### Phase 5 - 高级功能 (已完成 ✅)
|
||||
|
||||
- [x] 知识推理与问答增强 ✅ (2026-02-19 完成)
|
||||
- [x] 实体属性扩展 ✅ (2026-02-20 完成)
|
||||
- [x] 时间线视图 ✅ (2026-02-19 完成)
|
||||
- [x] 导出功能 ✅ (2026-02-20 完成)
|
||||
- 知识图谱导出 PNG/SVG
|
||||
- 项目报告导出 PDF
|
||||
- 实体数据导出 Excel/CSV
|
||||
- 关系数据导出 CSV
|
||||
- 转录文本导出 Markdown
|
||||
- 项目完整数据导出 JSON
|
||||
- [x] Neo4j 图数据库集成 ✅ (2026-02-21 完成)
|
||||
- 路径可视化优化(动画效果、发光效果、路径信息面板)
|
||||
- 社区可视化增强(聚焦功能、社区内连线、密度统计)
|
||||
|
||||
## 最近更新
|
||||
|
||||
### 2026-02-21
|
||||
- 完成 Phase 5 Neo4j 图数据库集成优化
|
||||
- 路径可视化优化:
|
||||
- 添加流动虚线动画效果,直观展示路径走向
|
||||
- 起点和终点节点添加发光效果,突出显示
|
||||
- 路径信息面板显示路径长度、节点数统计
|
||||
- 添加渐变色彩连接线
|
||||
- 社区可视化增强:
|
||||
- 点击社区列表可聚焦显示特定社区
|
||||
- 非聚焦社区自动淡化,突出当前社区
|
||||
- 社区内节点添加连线显示内部关联
|
||||
- 社区列表显示密度统计信息
|
||||
- 邻居查询可视化优化:
|
||||
- 中心节点添加发光效果
|
||||
- 连线添加淡入效果
|
||||
- 提交代码到 git 仓库
|
||||
- 部署到服务器: 122.51.127.111:18000
|
||||
773
frontend/app.js
773
frontend/app.js
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user