diff --git a/STATUS.md b/STATUS.md
index 9b53367..827ec61 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -146,9 +146,16 @@ Phase 5: 高级功能 - **进行中 🚧**
- `GET /api/v1/projects/{id}/graph/communities` - 社区发现
- `POST /api/v1/graph/subgraph` - 子图提取
- [x] 部署 Neo4j 服务 (docker-compose)
-- [ ] 前端图分析面板
-- [ ] 路径可视化
-- [ ] 社区可视化
+- [x] 前端图分析面板
+ - 图统计信息展示(节点数、边数、密度、连通分量)
+ - 度中心性排名展示
+ - 社区发现可视化(D3.js 力导向图)
+ - 最短路径查询和可视化
+ - 邻居节点查询和可视化
+ - Neo4j 连接状态指示
+ - 数据同步按钮
+- [ ] 路径可视化优化
+- [ ] 社区可视化增强
### Phase 4 - Neo4j 集成 (可选)
- [ ] 将图谱数据同步到 Neo4j
diff --git a/frontend/app.js b/frontend/app.js
index ec51774..2179baf 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -1,4 +1,4 @@
-// InsightFlow Frontend - Phase 4 (Agent Assistant + Provenance)
+// InsightFlow Frontend - Phase 5 (Graph Analysis)
const API_BASE = '/api/v1';
let currentProject = null;
@@ -8,6 +8,12 @@ let projectRelations = [];
let projectEntities = [];
let entityDetailsCache = {};
+// Graph Analysis State
+let graphStats = null;
+let centralityData = null;
+let communitiesData = null;
+let currentPathData = null;
+
// Init
document.addEventListener('DOMContentLoaded', () => {
const isWorkbench = window.location.pathname.includes('workbench');
@@ -98,6 +104,9 @@ async function loadProjectData() {
renderGraph();
renderEntityList();
+ // 更新图分析面板的实体选择器
+ populateGraphEntitySelects();
+
} catch (err) {
console.error('Load project data failed:', err);
}
@@ -925,6 +934,7 @@ window.switchView = function(viewName) {
document.getElementById('knowledgeBaseView').classList.remove('show');
document.getElementById('timelineView').classList.remove('show');
document.getElementById('reasoningView').classList.remove('active');
+ document.getElementById('graphAnalysisView').classList.remove('active');
// 显示选中的视图
if (viewName === 'workbench') {
@@ -941,6 +951,10 @@ window.switchView = function(viewName) {
} else if (viewName === 'reasoning') {
document.getElementById('reasoningView').classList.add('active');
document.querySelector('.sidebar-btn:nth-child(4)').classList.add('active');
+ } else if (viewName === 'graph-analysis') {
+ document.getElementById('graphAnalysisView').classList.add('active');
+ document.querySelector('.sidebar-btn:nth-child(5)').classList.add('active');
+ initGraphAnalysis();
}
};
@@ -2025,3 +2039,1044 @@ style.textContent = `
}
`;
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 = '
暂无数据
';
+ return;
+ }
+
+ list.innerHTML = data.rankings.slice(0, 10).map((r, i) => `
+
+
${i + 1}
+
+
${r.entity_name}
+
得分: ${r.score.toFixed(3)}
+
+
+ `).join('');
+
+ } catch (err) {
+ console.error('Load centrality analysis failed:', err);
+ document.getElementById('centralityList').innerHTML = '加载失败
';
+ }
+};
+
+// 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 = '未发现社区
';
+ return;
+ }
+
+ list.innerHTML = data.communities.map((c, i) => `
+
+ `).join('');
+
+ } catch (err) {
+ console.error('Load communities failed:', err);
+ document.getElementById('communitiesList').innerHTML = '加载失败
';
+ }
+};
+
+// 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 = '';
+ resultsDiv.classList.add('show');
+ return;
+ }
+
+ // Build path display
+ const pathHtml = data.path.map((node, i) => {
+ const arrow = i < data.path.length - 1 ? '→' : '';
+ return `${node.name}${arrow}`;
+ }).join('');
+
+ resultsDiv.innerHTML = `
+
+ `;
+ 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 = '';
+ resultsDiv.classList.add('show');
+ return;
+ }
+
+ const entityName = projectEntities.find(e => e.id === entityId)?.name || entityId;
+
+ resultsDiv.innerHTML = `
+
+
+
+ ${data.neighbors.map(n => `${n.entity_name} (${n.relation_type})`).join('')}
+
+
+ `;
+ 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 = '';
+
+ 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;
+
+ // 填充实体选择器
+ populateEntitySelectors();
+
+ // 加载图统计
+ loadGraphStats();
+
+ // 检查 Neo4j 状态
+ checkNeo4jStatus();
+}
+
+function populateEntitySelectors() {
+ const selectors = [
+ document.getElementById('pathStartEntity'),
+ document.getElementById('pathEndEntity'),
+ document.getElementById('neighborEntity')
+ ];
+
+ selectors.forEach(selector => {
+ if (!selector) return;
+
+ const currentValue = selector.value;
+ selector.innerHTML = '';
+
+ projectEntities.forEach(ent => {
+ const option = document.createElement('option');
+ option.value = ent.id;
+ option.textContent = `${ent.name} (${ent.type})`;
+ selector.appendChild(option);
+ });
+
+ selector.value = currentValue;
+ });
+}
+
+async function checkNeo4jStatus() {
+ try {
+ const res = await fetch(`${API_BASE}/neo4j/status`);
+ if (res.ok) {
+ const data = await res.json();
+ updateNeo4jStatusUI(data.connected);
+ }
+ } catch (err) {
+ console.error('Check Neo4j status failed:', err);
+ updateNeo4jStatusUI(false);
+ }
+}
+
+function updateNeo4jStatusUI(connected) {
+ // 可以在头部添加状态指示器
+ const header = document.querySelector('.graph-analysis-header');
+ let statusEl = document.getElementById('neo4jStatus');
+
+ if (!statusEl) {
+ statusEl = document.createElement('div');
+ statusEl.id = 'neo4jStatus';
+ statusEl.className = 'neo4j-status';
+ header.appendChild(statusEl);
+ }
+
+ statusEl.className = `neo4j-status ${connected ? 'connected' : 'disconnected'}`;
+ statusEl.innerHTML = `
+
+ Neo4j ${connected ? '已连接' : '未连接'}
+ `;
+}
+
+async function syncToNeo4j() {
+ if (!currentProject) return;
+
+ const btn = event.target;
+ const originalText = btn.textContent;
+ btn.textContent = '🔄 同步中...';
+ btn.disabled = true;
+
+ try {
+ 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 data = await res.json();
+ showNotification(`同步成功!${data.nodes_synced} 个节点, ${data.edges_synced} 条边`, 'success');
+
+ // 刷新统计
+ await loadGraphStats();
+ checkNeo4jStatus();
+
+ } catch (err) {
+ console.error('Sync to Neo4j failed:', err);
+ showNotification('同步失败,请检查 Neo4j 连接', 'error');
+ } finally {
+ btn.textContent = originalText;
+ btn.disabled = false;
+ }
+}
+
+async function loadGraphStats() {
+ if (!currentProject) return;
+
+ try {
+ // 加载图统计
+ const statsRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/stats`);
+ if (statsRes.ok) {
+ graphStats = await statsRes.json();
+ renderGraphStats(graphStats);
+ }
+
+ // 加载中心性分析
+ const centralityRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/centrality`);
+ if (centralityRes.ok) {
+ centralityData = await centralityRes.json();
+ renderCentrality(centralityData);
+ }
+
+ // 加载社区发现
+ const communitiesRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/communities`);
+ if (communitiesRes.ok) {
+ communitiesData = await communitiesRes.json();
+ renderCommunities(communitiesData);
+ }
+
+ } catch (err) {
+ console.error('Load graph stats failed:', err);
+ }
+}
+
+function renderGraphStats(stats) {
+ 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.component_count || 0;
+}
+
+function renderCentrality(data) {
+ const container = document.getElementById('centralityList');
+
+ if (!data.centrality || data.centrality.length === 0) {
+ container.innerHTML = '暂无中心性数据
';
+ return;
+ }
+
+ // 按度中心性排序
+ const sorted = [...data.centrality].sort((a, b) => b.degree - a.degree);
+
+ container.innerHTML = sorted.map((item, index) => {
+ const rank = index + 1;
+ const isTop3 = rank <= 3;
+ const entity = projectEntities.find(e => e.id === item.entity_id);
+
+ return `
+
+
${rank}
+
+
${item.entity_name}
+
${item.entity_type}${entity ? ` · ${entity.definition?.substring(0, 30) || ''}` : ''}
+
+
+
+ `;
+ }).join('');
+}
+
+function renderCommunities(data) {
+ const svg = d3.select('#communitiesSvg');
+ svg.selectAll('*').remove();
+
+ const container = document.getElementById('communitiesList');
+
+ if (!data.communities || data.communities.length === 0) {
+ container.innerHTML = '暂无社区数据
';
+ return;
+ }
+
+ // 渲染社区列表
+ container.innerHTML = data.communities.map((community, idx) => {
+ const nodeNames = community.node_names || [];
+ return `
+
+ `;
+ }).join('');
+
+ // 渲染社区可视化
+ renderCommunitiesViz(data.communities);
+}
+
+function renderCommunitiesViz(communities) {
+ const svg = d3.select('#communitiesSvg');
+ const container = svg.node().parentElement;
+ const width = container.clientWidth;
+ const height = container.clientHeight || 400;
+
+ svg.attr('width', width).attr('height', height);
+
+ // 颜色方案
+ const colors = [
+ '#00d4ff', '#7b2cbf', '#ff6b6b', '#4ecdc4',
+ '#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea'
+ ];
+
+ // 准备节点数据
+ let allNodes = [];
+ communities.forEach((comm, idx) => {
+ const nodes = (comm.node_names || []).map((name, i) => ({
+ id: `${idx}-${i}`,
+ name: name,
+ community: idx,
+ color: colors[idx % colors.length]
+ }));
+ allNodes = allNodes.concat(nodes);
+ });
+
+ if (allNodes.length === 0) return;
+
+ // 使用力导向布局
+ 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));
+
+ // 绘制节点
+ const node = svg.selectAll('.community-node')
+ .data(allNodes)
+ .enter().append('g')
+ .attr('class', 'community-node')
+ .call(d3.drag()
+ .on('start', dragstarted)
+ .on('drag', dragged)
+ .on('end', dragended));
+
+ node.append('circle')
+ .attr('r', 20)
+ .attr('fill', d => d.color)
+ .attr('stroke', '#fff')
+ .attr('stroke-width', 2)
+ .attr('opacity', 0.8);
+
+ node.append('text')
+ .text(d => d.name.length > 4 ? d.name.slice(0, 3) + '...' : d.name)
+ .attr('text-anchor', 'middle')
+ .attr('dy', 5)
+ .attr('fill', '#fff')
+ .attr('font-size', '10px')
+ .style('pointer-events', 'none');
+
+ simulation.on('tick', () => {
+ 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.switchGraphTab = function(tabName) {
+ // 更新标签状态
+ document.querySelectorAll('.graph-analysis-tab').forEach(tab => {
+ tab.classList.remove('active');
+ });
+ event.target.classList.add('active');
+
+ // 切换面板
+ document.querySelectorAll('.graph-viz-panel').forEach(panel => {
+ panel.classList.remove('active');
+ });
+
+ if (tabName === 'centrality') {
+ document.getElementById('centralityPanel').classList.add('active');
+ } else if (tabName === 'communities') {
+ document.getElementById('communitiesPanel').classList.add('active');
+ }
+};
+
+async function findShortestPath() {
+ const startId = document.getElementById('pathStartEntity').value;
+ const endId = document.getElementById('pathEndEntity').value;
+
+ if (!startId || !endId) {
+ alert('请选择起点和终点实体');
+ return;
+ }
+
+ if (startId === endId) {
+ alert('起点和终点不能相同');
+ return;
+ }
+
+ // 切换到路径面板
+ document.querySelectorAll('.graph-viz-panel').forEach(panel => {
+ panel.classList.remove('active');
+ });
+ document.getElementById('pathPanel').classList.add('active');
+
+ // 显示加载状态
+ document.getElementById('pathViz').innerHTML = `
+
+ `;
+
+ try {
+ const res = await fetch(`${API_BASE}/graph/shortest-path`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ start_entity_id: startId,
+ end_entity_id: endId
+ })
+ });
+
+ if (!res.ok) throw new Error('Path finding failed');
+
+ const data = await res.json();
+ currentPathData = data;
+ renderPath(data);
+
+ } catch (err) {
+ console.error('Find shortest path failed:', err);
+ document.getElementById('pathViz').innerHTML = `
+
+
❌
+
路径查找失败
+
请确保数据已同步到 Neo4j
+
+ `;
+ }
+}
+
+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);
+
+ document.getElementById('pathDescription').textContent =
+ `${startEntity?.name || '起点'} → ${endEntity?.name || '终点'} (${data.path_length} 步)`;
+
+ // 渲染路径可视化
+ const svg = d3.select('#pathSvg');
+ svg.selectAll('*').remove();
+
+ const container = svg.node().parentElement;
+ const width = container.clientWidth;
+ const height = container.clientHeight || 300;
+
+ svg.attr('width', width).attr('height', height);
+
+ if (!data.path || data.path.length === 0) {
+ document.getElementById('pathViz').innerHTML = `
+
+ `;
+ document.getElementById('pathInfo').innerHTML = '';
+ return;
+ }
+
+ // 准备节点和边
+ const nodes = data.path.map((nodeId, idx) => ({
+ id: nodeId,
+ name: projectEntities.find(e => e.id === nodeId)?.name || nodeId,
+ x: (width / (data.path.length + 1)) * (idx + 1),
+ y: height / 2
+ }));
+
+ const links = [];
+ for (let i = 0; i < nodes.length - 1; i++) {
+ links.push({
+ source: nodes[i],
+ target: nodes[i + 1]
+ });
+ }
+
+ // 绘制连线
+ svg.selectAll('.path-link')
+ .data(links)
+ .enter().append('line')
+ .attr('class', 'path-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)
+ .attr('stroke', '#00d4ff')
+ .attr('stroke-width', 3)
+ .attr('stroke-opacity', 0.6);
+
+ // 绘制箭头
+ 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);
+
+ svg.append('polygon')
+ .attr('points', `0,-${arrowSize/2} ${arrowSize},0 0,${arrowSize/2}`)
+ .attr('transform', `translate(${arrowX},${arrowY}) rotate(${angle * 180 / Math.PI})`)
+ .attr('fill', '#00d4ff');
+ });
+
+ // 绘制节点
+ const node = svg.selectAll('.path-node')
+ .data(nodes)
+ .enter().append('g')
+ .attr('class', 'path-node')
+ .attr('transform', d => `translate(${d.x},${d.y})`);
+
+ node.append('circle')
+ .attr('r', d => d.id === data.start_entity_id || d.id === data.end_entity_id ? 30 : 25)
+ .attr('fill', d => {
+ if (d.id === data.start_entity_id) return '#00d4ff';
+ if (d.id === data.end_entity_id) return '#7b2cbf';
+ return '#333';
+ })
+ .attr('stroke', '#fff')
+ .attr('stroke-width', 2);
+
+ 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.id === data.start_entity_id || d.id === data.end_entity_id) ? '#000' : '#fff')
+ .attr('font-size', '11px')
+ .style('pointer-events', 'none');
+
+ // 渲染路径信息
+ renderPathInfo(data);
+}
+
+function renderPathInfo(data) {
+ const container = document.getElementById('pathInfo');
+
+ let html = '';
+ data.path.forEach((nodeId, idx) => {
+ const entity = projectEntities.find(e => e.id === nodeId);
+ const isStart = idx === 0;
+ const isEnd = idx === data.path.length - 1;
+
+ html += `
+
+
${idx + 1}
+
+
${entity?.name || nodeId}
+ ${!isStart ? `
← 通过关系连接
` : ''}
+
+ ${isStart ? '
起点' : ''}
+ ${isEnd ? '
终点' : ''}
+
+ `;
+ });
+
+ container.innerHTML = html;
+}
+
+async function findNeighbors() {
+ const entityId = document.getElementById('neighborEntity').value;
+ const depth = parseInt(document.getElementById('neighborDepth').value) || 1;
+
+ if (!entityId) {
+ alert('请选择实体');
+ return;
+ }
+
+ // 切换到路径面板显示邻居
+ document.querySelectorAll('.graph-viz-panel').forEach(panel => {
+ panel.classList.remove('active');
+ });
+ document.getElementById('pathPanel').classList.add('active');
+
+ const entity = projectEntities.find(e => e.id === entityId);
+ document.getElementById('pathDescription').textContent =
+ `${entity?.name || '实体'} 的 ${depth} 度邻居`;
+
+ // 显示加载状态
+ document.getElementById('pathViz').innerHTML = `
+
+ `;
+ document.getElementById('pathInfo').innerHTML = '';
+
+ try {
+ const res = await fetch(`${API_BASE}/entities/${entityId}/neighbors?depth=${depth}`);
+
+ if (!res.ok) throw new Error('Neighbors query failed');
+
+ const data = await res.json();
+ renderNeighbors(data, entity);
+
+ } catch (err) {
+ console.error('Find neighbors failed:', err);
+ document.getElementById('pathViz').innerHTML = `
+
+
❌
+
邻居查询失败
+
请确保数据已同步到 Neo4j
+
+ `;
+ }
+}
+
+function renderNeighbors(data, centerEntity) {
+ const svg = d3.select('#pathSvg');
+ svg.selectAll('*').remove();
+
+ const container = svg.node().parentElement;
+ const width = container.clientWidth;
+ const height = container.clientHeight || 300;
+
+ svg.attr('width', width).attr('height', height);
+
+ const neighbors = data.neighbors || [];
+
+ if (neighbors.length === 0) {
+ document.getElementById('pathViz').innerHTML = `
+
+ `;
+ return;
+ }
+
+ // 中心节点
+ const centerNode = {
+ id: centerEntity.id,
+ name: centerEntity.name,
+ x: width / 2,
+ y: height / 2,
+ isCenter: true
+ };
+
+ // 邻居节点 - 环形布局
+ const radius = Math.min(width, height) / 3;
+ const neighborNodes = neighbors.map((n, idx) => ({
+ id: n.entity_id,
+ name: n.entity_name,
+ x: width / 2 + radius * Math.cos((2 * Math.PI * idx) / neighbors.length - Math.PI / 2),
+ y: height / 2 + radius * Math.sin((2 * Math.PI * idx) / neighbors.length - Math.PI / 2),
+ relationType: n.relation_type
+ }));
+
+ const allNodes = [centerNode, ...neighborNodes];
+
+ // 绘制连线
+ 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', 2)
+ .attr('stroke-opacity', 0.4);
+ });
+
+ // 绘制节点
+ const node = svg.selectAll('.neighbor-node')
+ .data(allNodes)
+ .enter().append('g')
+ .attr('class', 'neighbor-node')
+ .attr('transform', d => `translate(${d.x},${d.y})`);
+
+ node.append('circle')
+ .attr('r', d => d.isCenter ? 35 : 25)
+ .attr('fill', d => d.isCenter ? '#00d4ff' : '#333')
+ .attr('stroke', '#fff')
+ .attr('stroke-width', 2);
+
+ 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')
+ .style('pointer-events', 'none');
+
+ // 渲染邻居信息
+ let html = `找到 ${neighbors.length} 个邻居节点:
`;
+ neighbors.forEach((n, idx) => {
+ html += `
+
+
${idx + 1}
+
+
${n.entity_name}
+
关系: ${n.relation_type}
+
+
+ `;
+ });
+ document.getElementById('pathInfo').innerHTML = html;
+}
+
+// Show notification helper
+function showNotification(message, type = 'info') {
+ const notification = document.createElement('div');
+ notification.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ background: ${type === 'success' ? 'rgba(0, 212, 255, 0.9)' : type === 'error' ? 'rgba(255, 107, 107, 0.9)' : '#333'};
+ color: ${type === 'success' || type === 'error' ? '#000' : '#fff'};
+ padding: 12px 20px;
+ border-radius: 8px;
+ z-index: 10000;
+ font-size: 0.9rem;
+ animation: slideIn 0.3s ease;
+ `;
+ notification.textContent = message;
+
+ document.body.appendChild(notification);
+
+ setTimeout(() => {
+ notification.style.animation = 'slideOut 0.3s ease';
+ setTimeout(() => {
+ if (notification.parentNode) {
+ document.body.removeChild(notification);
+ }
+ }, 300);
+ }, 3000);
+}
diff --git a/frontend/workbench.html b/frontend/workbench.html
index 06b614b..c63f47f 100644
--- a/frontend/workbench.html
+++ b/frontend/workbench.html
@@ -1471,6 +1471,460 @@
color: #00d4ff;
text-align: center;
}
+
+ /* Phase 5: Graph Analysis Panel */
+ .graph-analysis-panel {
+ display: none;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ background: #0a0a0a;
+ }
+
+ .graph-analysis-panel.active {
+ display: flex;
+ }
+
+ .graph-analysis-header {
+ padding: 16px 20px;
+ background: #141414;
+ border-bottom: 1px solid #222;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .graph-analysis-header h2 {
+ font-size: 1.3rem;
+ margin-bottom: 4px;
+ }
+
+ .graph-analysis-actions {
+ display: flex;
+ gap: 10px;
+ }
+
+ .graph-analysis-content {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ }
+
+ .graph-analysis-sidebar {
+ width: 320px;
+ background: #111;
+ border-right: 1px solid #222;
+ padding: 16px;
+ overflow-y: auto;
+ }
+
+ .graph-analysis-section {
+ margin-bottom: 24px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #222;
+ }
+
+ .graph-analysis-section:last-child {
+ border-bottom: none;
+ }
+
+ .graph-analysis-section h4 {
+ color: #00d4ff;
+ font-size: 0.9rem;
+ margin-bottom: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .graph-stats-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ }
+
+ .graph-stat-item {
+ background: #141414;
+ border: 1px solid #222;
+ border-radius: 8px;
+ padding: 12px;
+ text-align: center;
+ }
+
+ .graph-stat-value {
+ display: block;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: #00d4ff;
+ margin-bottom: 4px;
+ }
+
+ .graph-stat-label {
+ font-size: 0.75rem;
+ color: #666;
+ }
+
+ .graph-query-group {
+ background: #141414;
+ border: 1px solid #222;
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 12px;
+ }
+
+ .graph-query-group label {
+ display: block;
+ color: #888;
+ font-size: 0.8rem;
+ margin-bottom: 8px;
+ }
+
+ .graph-query-select,
+ .graph-query-input {
+ width: 100%;
+ background: #1a1a1a;
+ border: 1px solid #333;
+ border-radius: 6px;
+ padding: 8px 12px;
+ color: #e0e0e0;
+ font-size: 0.85rem;
+ margin-bottom: 8px;
+ }
+
+ .graph-query-select:focus,
+ .graph-query-input:focus {
+ outline: none;
+ border-color: #00d4ff;
+ }
+
+ .graph-query-options {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+
+ .graph-query-options label {
+ margin-bottom: 0;
+ }
+
+ .graph-query-input {
+ width: 60px;
+ margin-bottom: 0;
+ }
+
+ .graph-analysis-tabs {
+ display: flex;
+ gap: 8px;
+ }
+
+ .graph-analysis-tab {
+ flex: 1;
+ padding: 10px;
+ background: #141414;
+ border: 1px solid #222;
+ border-radius: 6px;
+ color: #888;
+ cursor: pointer;
+ font-size: 0.85rem;
+ transition: all 0.2s;
+ }
+
+ .graph-analysis-tab:hover {
+ border-color: #00d4ff;
+ color: #00d4ff;
+ }
+
+ .graph-analysis-tab.active {
+ background: #00d4ff22;
+ border-color: #00d4ff;
+ color: #00d4ff;
+ }
+
+ .graph-analysis-viz {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .graph-viz-panel {
+ display: none;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .graph-viz-panel.active {
+ display: flex;
+ }
+
+ .graph-viz-header {
+ padding: 16px 20px;
+ background: #141414;
+ border-bottom: 1px solid #222;
+ }
+
+ .graph-viz-header h3 {
+ font-size: 1.1rem;
+ margin-bottom: 4px;
+ }
+
+ .centrality-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ }
+
+ .centrality-item {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ background: #141414;
+ border: 1px solid #222;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+
+ .centrality-item:hover {
+ border-color: #00d4ff;
+ background: #1a1a1a;
+ }
+
+ .centrality-rank {
+ width: 32px;
+ height: 32px;
+ background: #00d4ff22;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #00d4ff;
+ font-weight: 600;
+ font-size: 0.9rem;
+ margin-right: 12px;
+ }
+
+ .centrality-rank.top3 {
+ background: #00d4ff;
+ color: #000;
+ }
+
+ .centrality-info {
+ flex: 1;
+ }
+
+ .centrality-name {
+ font-weight: 500;
+ color: #e0e0e0;
+ margin-bottom: 2px;
+ }
+
+ .centrality-type {
+ font-size: 0.75rem;
+ color: #666;
+ }
+
+ .centrality-score {
+ text-align: right;
+ }
+
+ .centrality-value {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: #00d4ff;
+ }
+
+ .centrality-label {
+ font-size: 0.75rem;
+ color: #666;
+ }
+
+ .communities-viz {
+ flex: 1;
+ background: #0a0a0a;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .communities-viz svg {
+ width: 100%;
+ height: 100%;
+ }
+
+ .communities-list {
+ max-height: 200px;
+ overflow-y: auto;
+ padding: 12px 16px;
+ background: #111;
+ border-top: 1px solid #222;
+ }
+
+ .community-item {
+ padding: 10px 12px;
+ background: #141414;
+ border-radius: 6px;
+ margin-bottom: 8px;
+ }
+
+ .community-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+ }
+
+ .community-name {
+ font-weight: 500;
+ color: #e0e0e0;
+ }
+
+ .community-size {
+ font-size: 0.75rem;
+ color: #00d4ff;
+ background: #00d4ff22;
+ padding: 2px 8px;
+ border-radius: 10px;
+ }
+
+ .community-nodes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ .community-node-tag {
+ font-size: 0.75rem;
+ color: #888;
+ background: #1a1a1a;
+ padding: 2px 8px;
+ border-radius: 4px;
+ }
+
+ .path-viz {
+ flex: 1;
+ background: #0a0a0a;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .path-viz svg {
+ width: 100%;
+ height: 100%;
+ }
+
+ .path-info {
+ max-height: 150px;
+ overflow-y: auto;
+ padding: 12px 16px;
+ background: #111;
+ border-top: 1px solid #222;
+ }
+
+ .path-step {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 0;
+ border-bottom: 1px solid #222;
+ }
+
+ .path-step:last-child {
+ border-bottom: none;
+ }
+
+ .path-step-number {
+ width: 24px;
+ height: 24px;
+ background: #00d4ff;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #000;
+ font-size: 0.75rem;
+ font-weight: 600;
+ }
+
+ .path-step-content {
+ flex: 1;
+ }
+
+ .path-step-entity {
+ font-weight: 500;
+ color: #e0e0e0;
+ }
+
+ .path-step-relation {
+ font-size: 0.8rem;
+ color: #00d4ff;
+ }
+
+ .graph-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: #666;
+ }
+
+ .graph-empty-state-icon {
+ font-size: 3rem;
+ margin-bottom: 16px;
+ opacity: 0.5;
+ }
+
+ .graph-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: #00d4ff;
+ }
+
+ .graph-loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid #222;
+ border-top-color: #00d4ff;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 12px;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ .neo4j-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.8rem;
+ padding: 4px 10px;
+ border-radius: 4px;
+ }
+
+ .neo4j-status.connected {
+ background: #00d4ff22;
+ color: #00d4ff;
+ }
+
+ .neo4j-status.disconnected {
+ background: #ff6b6b22;
+ color: #ff6b6b;
+ }
+
+ .neo4j-status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: currentColor;
+ }
@@ -1491,6 +1945,7 @@
+
@@ -1696,6 +2151,115 @@
+
+
+
+
+
+
+ -
+ 节点数
+
+
+ -
+ 关系数
+
+
+ -
+ 图密度
+
+
+ -
+ 连通分量
+
+
+
+
+
+
+
+
+
+
+
+
+ 项目
+
+
+
+ 技术
+
+
+
+ 人物
+
+
+
+ 组织
+
+
+
+ 其他
+
+
+
+
+
+
+
+
-
-
-
-
-