From f38e060fa7e7de978d2c5e6c13a7822e90f62b0d Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sat, 21 Feb 2026 00:14:47 +0800 Subject: [PATCH] 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 --- STATUS.md | 16 +- STATUS_update.md | 35 +++ frontend/app.js | 773 +++++++++++++++++++++-------------------------- 3 files changed, 393 insertions(+), 431 deletions(-) create mode 100644 STATUS_update.md diff --git a/STATUS.md b/STATUS.md index 703fe1e..e6023f9 100644 --- a/STATUS.md +++ b/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 diff --git a/STATUS_update.md b/STATUS_update.md new file mode 100644 index 0000000..795d9c8 --- /dev/null +++ b/STATUS_update.md @@ -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 \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js index 2179baf..8653215 100644 --- a/frontend/app.js +++ b/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 = '

暂无数据

'; - 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; @@ -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 ` -
+
社区 ${idx + 1} ${community.size} 个节点
+
密度: ${density}
${nodeNames.slice(0, 8).map(name => ` ${name} @@ -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 = ` +
+
+ 路径长度 + ${steps} 步 +
+
+ 节点数 + ${pathLength} 个 +
+
+ `; + 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 += `
-
${idx + 1}
+
${idx + 1}
${entity?.name || nodeId}
${!isStart ? `
← 通过关系连接
` : ''} @@ -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 = `

找到 ${neighbors.length} 个邻居节点:

`; + let html = ` +
+
+ 邻居节点数 + ${neighbors.length} 个 +
+
+ `; + neighbors.forEach((n, idx) => { html += `
@@ -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); +}