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) => ` +
+
+ 社区 ${i + 1} + ${c.size} 节点 +
+
密度: ${(c.density || 0).toFixed(3)}
+
+ ${c.nodes.slice(0, 5).map(n => `${n.name}`).join('')} + ${c.nodes.length > 5 ? `+${c.nodes.length - 5}` : ''} +
+
+ `).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 = ` +
+
最短路径 (${data.path.length} 个节点, 长度: ${data.path_length})
+
${pathHtml}
+
+ `; + 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 = ` +
+
${entityName} 的邻居节点 (${data.neighbors.length})
+
+ ${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) || ''}` : ''}
+
+
+
${item.degree}
+
连接数
+
+
+ `; + }).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 ` +
+
+ 社区 ${idx + 1} + ${community.size} 个节点 +
+
+ ${nodeNames.slice(0, 8).map(name => ` + ${name} + `).join('')} + ${nodeNames.length > 8 ? `+${nodeNames.length - 8}` : ''} +
+
+ `; + }).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 @@ + +
+
+

🕸️ 图分析

+

基于 Neo4j 的图数据库分析与可视化

+
+ +
+
+ - + 节点数 +
+
+ - + 关系数 +
+
+ - + 图密度 +
+
+ - + 连通分量 +
+
+ +
+
+
+

🔍 最短路径查询

+
+
+ + +
+
+ + +
+ +
+
+ +
+

📊 中心性分析

+
+ + +
+
+
+ +
+

👥 社区发现

+ +
+
+ +
+

🌐 邻居查询

+
+ + +
+ +
+
+ +
+
+ 图可视化 +
+ + +
+
+ +
+
+ + 项目 +
+
+ + 技术 +
+
+ + 人物 +
+
+ + 组织 +
+
+ + 其他 +
+
+
+
+ +
+
+
@@ -1759,11 +2323,9 @@
- - - - -
+ + +
TYPE Entity Name