Browse Source

feat: 优化边和节点交互体验

- 点击边时相关图只显示边和两端节点
- 相关图hover节点时只显示到该节点的路径
- 分数标签不阻挡边的点击和hover
- 统一所有节点大小使用getNodeStyle配置
- 移除相关图placeholder

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 1 day ago
parent
commit
48a4925dc0

+ 90 - 25
script/visualization/src/components/GraphView.vue

@@ -81,10 +81,6 @@
     <!-- SVG 容器 -->
     <!-- SVG 容器 -->
     <div ref="containerRef" class="flex-1 relative overflow-hidden">
     <div ref="containerRef" class="flex-1 relative overflow-hidden">
       <svg ref="svgRef" class="w-full h-full transition-opacity duration-200"></svg>
       <svg ref="svgRef" class="w-full h-full transition-opacity duration-200"></svg>
-      <!-- 未选中节点时的提示 -->
-      <div v-if="!store.selectedNodeId" class="absolute inset-0 flex items-center justify-center text-base-content/30 text-sm">
-        点击人设树节点查看相关图
-      </div>
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
@@ -132,6 +128,49 @@ const currentNodeName = computed(() => {
 const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(store.selectedNodeId))
 const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(store.selectedNodeId))
 const isPostWalk = computed(() => store.selectedNodeId && store.shouldPostWalk(store.selectedNodeId))
 const isPostWalk = computed(() => store.selectedNodeId && store.shouldPostWalk(store.selectedNodeId))
 
 
+// BFS 找从 start 到 end 的路径,返回路径上所有节点 ID 的 Set
+function findPath(startId, endId, links) {
+  // 构建邻接表(无向)
+  const adj = new Map()
+  for (const link of links) {
+    const src = link.source.id || link.source
+    const tgt = link.target.id || link.target
+    if (!adj.has(src)) adj.set(src, [])
+    if (!adj.has(tgt)) adj.set(tgt, [])
+    adj.get(src).push(tgt)
+    adj.get(tgt).push(src)
+  }
+
+  // BFS
+  const visited = new Set([startId])
+  const parent = new Map()
+  const queue = [startId]
+
+  while (queue.length > 0) {
+    const curr = queue.shift()
+    if (curr === endId) break
+
+    for (const neighbor of (adj.get(curr) || [])) {
+      if (!visited.has(neighbor)) {
+        visited.add(neighbor)
+        parent.set(neighbor, curr)
+        queue.push(neighbor)
+      }
+    }
+  }
+
+  // 回溯路径
+  const pathNodes = new Set()
+  if (visited.has(endId)) {
+    let curr = endId
+    while (curr) {
+      pathNodes.add(curr)
+      curr = parent.get(curr)
+    }
+  }
+  return pathNodes
+}
+
 // 渲染相关图
 // 渲染相关图
 function renderGraph() {
 function renderGraph() {
   // 停止旧的 simulation
   // 停止旧的 simulation
@@ -143,10 +182,11 @@ function renderGraph() {
   const svg = d3.select(svgRef.value)
   const svg = d3.select(svgRef.value)
   svg.selectAll('*').remove()
   svg.selectAll('*').remove()
 
 
-  if (!store.selectedNodeId) return
+  // 有选中节点或选中边时才渲染
+  if (!store.selectedNodeId && !store.selectedEdgeId) return
 
 
-  // 只有配置的节点类型才显示相关图(人设节点或帖子标签节点)
-  if (!store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
+  // 选中节点时,只有配置的节点类型才显示相关图
+  if (store.selectedNodeId && !store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
 
 
   const container = containerRef.value
   const container = containerRef.value
   if (!container) return
   if (!container) return
@@ -157,17 +197,15 @@ function renderGraph() {
 
 
   svg.attr('viewBox', `0 0 ${width} ${height}`)
   svg.attr('viewBox', `0 0 ${width} ${height}`)
 
 
-  const centerNodeId = store.selectedNodeId
-  const centerNode = store.getNode(centerNodeId)
-
-  if (!centerNode) return
+  // 中心节点(选中节点,或边的第一个端点)
+  const centerNodeId = store.selectedNodeId || Array.from(store.highlightedNodeIds)[0]
 
 
   // 准备节点和边数据
   // 准备节点和边数据
   const nodes = []
   const nodes = []
   const links = []
   const links = []
   const nodeSet = new Set()
   const nodeSet = new Set()
 
 
-  // 始终使用游走模式:显示所有高亮节点和走过的边
+  // 显示所有高亮节点
   for (const nodeId of store.highlightedNodeIds) {
   for (const nodeId of store.highlightedNodeIds) {
     const nodeData = store.getNode(nodeId)
     const nodeData = store.getNode(nodeId)
     if (nodeData) {
     if (nodeData) {
@@ -181,16 +219,8 @@ function renderGraph() {
     }
     }
   }
   }
 
 
-  // 如果高亮集合为空(不应该发生),至少显示中心节点
-  if (nodes.length === 0) {
-    nodes.push({
-      id: centerNodeId,
-      ...centerNode,
-      isCenter: true,
-      isHighlighted: false
-    })
-    nodeSet.add(centerNodeId)
-  }
+  // 如果没有节点,不渲染
+  if (nodes.length === 0) return
 
 
   // 使用游走时记录的边(只显示两端节点都存在的边)
   // 使用游走时记录的边(只显示两端节点都存在的边)
   // 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
   // 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
@@ -233,8 +263,13 @@ function renderGraph() {
     .style('cursor', 'pointer')
     .style('cursor', 'pointer')
     .on('click', (e, d) => {
     .on('click', (e, d) => {
       e.stopPropagation()
       e.stopPropagation()
-      const edgeId = `${d.source.id || d.source}|${d.type}|${d.target.id || d.target}`
-      store.selectEdge(edgeId)
+      // 传入完整边数据
+      store.selectEdge({
+        source: d.source.id || d.source,
+        target: d.target.id || d.target,
+        type: d.type,
+        score: d.score
+      })
     })
     })
 
 
   // 边的分数标签
   // 边的分数标签
@@ -286,6 +321,31 @@ function renderGraph() {
       e.stopPropagation()
       e.stopPropagation()
       store.selectNode(d.id)
       store.selectNode(d.id)
     })
     })
+    .on('mouseenter', (e, d) => {
+      if (d.isCenter) return  // 中心节点不处理
+      // 找从中心到 hover 节点的路径
+      const pathNodes = findPath(centerNodeId, d.id, links)
+      if (pathNodes.size > 0) {
+        // 高亮路径上的节点和边
+        node.classed('dimmed', n => !pathNodes.has(n.id))
+        link.classed('dimmed', l => {
+          const srcId = l.source.id || l.source
+          const tgtId = l.target.id || l.target
+          return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
+        })
+        linkLabel.classed('dimmed', l => {
+          const srcId = l.source.id || l.source
+          const tgtId = l.target.id || l.target
+          return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
+        })
+      }
+    })
+    .on('mouseleave', () => {
+      // 恢复所有节点和边
+      node.classed('dimmed', false)
+      link.classed('dimmed', false)
+      linkLabel.classed('dimmed', false)
+    })
 
 
   // 节点形状(使用统一配置)
   // 节点形状(使用统一配置)
   node.each(function(d) {
   node.each(function(d) {
@@ -344,6 +404,11 @@ watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () =
   nextTick(renderGraph)
   nextTick(renderGraph)
 })
 })
 
 
+// 监听边选中变化
+watch(() => store.selectedEdgeId, () => {
+  nextTick(renderGraph)
+})
+
 // 监听配置变化,重新选中触发游走
 // 监听配置变化,重新选中触发游走
 watch([() => store.walkSteps, () => store.stepConfigs], () => {
 watch([() => store.walkSteps, () => store.stepConfigs], () => {
   if (store.selectedNodeId && isPersonaWalk.value) {
   if (store.selectedNodeId && isPersonaWalk.value) {
@@ -361,7 +426,7 @@ watch(() => store.postWalkConfig, () => {
 // 监听 CSS 过渡结束后重新渲染
 // 监听 CSS 过渡结束后重新渲染
 function handleTransitionEnd(e) {
 function handleTransitionEnd(e) {
   if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
   if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
-    if (store.selectedNodeId && svgRef.value) {
+    if ((store.selectedNodeId || store.selectedEdgeId) && svgRef.value) {
       renderGraph()
       renderGraph()
       svgRef.value.style.opacity = '1'
       svgRef.value.style.opacity = '1'
     }
     }

+ 28 - 16
script/visualization/src/components/PostTreeView.vue

@@ -206,14 +206,15 @@ function renderTree() {
   nodes.each(function(d) {
   nodes.each(function(d) {
     const el = d3.select(this)
     const el = d3.select(this)
     const style = getNodeStyle(d)
     const style = getNodeStyle(d)
+    const halfSize = style.size / 2
 
 
     if (style.shape === 'rect') {
     if (style.shape === 'rect') {
       el.append('rect')
       el.append('rect')
         .attr('class', 'tree-shape')
         .attr('class', 'tree-shape')
-        .attr('x', -4)
-        .attr('y', -4)
-        .attr('width', 8)
-        .attr('height', 8)
+        .attr('x', -halfSize)
+        .attr('y', -halfSize)
+        .attr('width', style.size)
+        .attr('height', style.size)
         .attr('rx', 1)
         .attr('rx', 1)
         .attr('fill', style.color)
         .attr('fill', style.color)
         .attr('stroke', 'rgba(255,255,255,0.5)')
         .attr('stroke', 'rgba(255,255,255,0.5)')
@@ -221,7 +222,7 @@ function renderTree() {
     } else {
     } else {
       el.append('circle')
       el.append('circle')
         .attr('class', 'tree-shape')
         .attr('class', 'tree-shape')
-        .attr('r', style.size / 2)
+        .attr('r', halfSize)
         .attr('fill', style.color)
         .attr('fill', style.color)
         .attr('stroke', 'rgba(255,255,255,0.5)')
         .attr('stroke', 'rgba(255,255,255,0.5)')
         .attr('stroke-width', 1)
         .attr('stroke-width', 1)
@@ -341,8 +342,12 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     .style('cursor', 'pointer')
     .style('cursor', 'pointer')
     .on('click', (e, d) => {
     .on('click', (e, d) => {
       e.stopPropagation()
       e.stopPropagation()
-      const edgeId = `${d.source}|匹配|${d.target}`
-      store.selectEdge(edgeId)
+      store.selectEdge({
+        source: d.source,
+        target: d.target,
+        type: '匹配',
+        score: d.score
+      })
     })
     })
     .attr('d', d => {
     .attr('d', d => {
       const midY = (d.srcY + d.tgtY) / 2
       const midY = (d.srcY + d.tgtY) / 2
@@ -393,14 +398,15 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
   matchNodes.each(function(d) {
   matchNodes.each(function(d) {
     const el = d3.select(this)
     const el = d3.select(this)
     const style = getNodeStyle(d, { isMatch: true })
     const style = getNodeStyle(d, { isMatch: true })
+    const halfSize = style.size / 2
 
 
     if (style.shape === 'rect') {
     if (style.shape === 'rect') {
       el.append('rect')
       el.append('rect')
         .attr('class', 'tree-shape')
         .attr('class', 'tree-shape')
-        .attr('x', -4)
-        .attr('y', -4)
-        .attr('width', 8)
-        .attr('height', 8)
+        .attr('x', -halfSize)
+        .attr('y', -halfSize)
+        .attr('width', style.size)
+        .attr('height', style.size)
         .attr('rx', 1)
         .attr('rx', 1)
         .attr('fill', style.color)
         .attr('fill', style.color)
         .attr('stroke', 'rgba(255,255,255,0.5)')
         .attr('stroke', 'rgba(255,255,255,0.5)')
@@ -408,7 +414,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     } else {
     } else {
       el.append('circle')
       el.append('circle')
         .attr('class', 'tree-shape')
         .attr('class', 'tree-shape')
-        .attr('r', 3)
+        .attr('r', halfSize)
         .attr('fill', style.color)
         .attr('fill', style.color)
         .attr('stroke', 'rgba(255,255,255,0.5)')
         .attr('stroke', 'rgba(255,255,255,0.5)')
         .attr('stroke-width', 1)
         .attr('stroke-width', 1)
@@ -615,8 +621,12 @@ function renderWalkedLayer() {
     .style('cursor', 'pointer')
     .style('cursor', 'pointer')
     .on('click', (e, d) => {
     .on('click', (e, d) => {
       e.stopPropagation()
       e.stopPropagation()
-      const edgeId = `${d.source}|${d.type}|${d.target}`
-      store.selectEdge(edgeId)
+      store.selectEdge({
+        source: d.source,
+        target: d.target,
+        type: d.type,
+        score: d.score
+      })
     })
     })
     .attr('d', d => {
     .attr('d', d => {
       // 同一层的边(Y坐标相近)用向下弯曲的曲线
       // 同一层的边(Y坐标相近)用向下弯曲的曲线
@@ -678,17 +688,19 @@ function renderWalkedLayer() {
   walkedNodeGroups.each(function(d) {
   walkedNodeGroups.each(function(d) {
     const el = d3.select(this)
     const el = d3.select(this)
     const style = getNodeStyle(d)
     const style = getNodeStyle(d)
+    const halfSize = style.size / 2
 
 
     if (style.shape === 'rect') {
     if (style.shape === 'rect') {
       el.append('rect')
       el.append('rect')
         .attr('class', 'walked-shape')
         .attr('class', 'walked-shape')
-        .attr('x', -4).attr('y', -4).attr('width', 8).attr('height', 8)
+        .attr('x', -halfSize).attr('y', -halfSize)
+        .attr('width', style.size).attr('height', style.size)
         .attr('rx', 1).attr('fill', style.color)
         .attr('rx', 1).attr('fill', style.color)
         .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
         .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
     } else {
     } else {
       el.append('circle')
       el.append('circle')
         .attr('class', 'walked-shape')
         .attr('class', 'walked-shape')
-        .attr('r', 3).attr('fill', style.color)
+        .attr('r', halfSize).attr('fill', style.color)
         .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
         .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
     }
     }
 
 

+ 31 - 3
script/visualization/src/stores/graph.js

@@ -426,10 +426,38 @@ export const useGraphStore = defineStore('graph', () => {
     }
     }
   }
   }
 
 
-  // 选中边
-  function selectEdge(edgeId) {
+  // 选中边(可传入边数据或边ID)
+  function selectEdge(edgeIdOrData) {
+    let edge = null
+    let edgeId = null
+
+    if (typeof edgeIdOrData === 'string') {
+      // 传入的是 edgeId,尝试查找
+      edgeId = edgeIdOrData
+      edge = getEdge(edgeId)
+      // 如果找不到,从 ID 解析出基本信息
+      if (!edge) {
+        const parts = edgeId.split('|')
+        if (parts.length === 3) {
+          edge = { source: parts[0], target: parts[2], type: parts[1] }
+        }
+      }
+    } else {
+      // 传入的是边数据对象
+      edge = edgeIdOrData
+      edgeId = `${edge.source}|${edge.type}|${edge.target}`
+    }
+
+    if (!edge) return
+
     selectedEdgeId.value = edgeId
     selectedEdgeId.value = edgeId
-    // 不清除节点选中,边详情作为补充信息
+    selectedNodeId.value = null  // 清除节点选中
+
+    // 只高亮边的两端节点
+    highlightedNodeIds.value = new Set([edge.source, edge.target])
+    // 只保留这条边
+    walkedEdges.value = [edge]
+    postWalkedEdges.value = []
   }
   }
 
 
   // 清除选中
   // 清除选中

+ 13 - 0
script/visualization/src/style.css

@@ -213,5 +213,18 @@
   .graph-link-label {
   .graph-link-label {
     @apply text-xs;
     @apply text-xs;
     fill: oklch(var(--bc) / 0.5);
     fill: oklch(var(--bc) / 0.5);
+    pointer-events: none;
+  }
+
+  /* 分数标签不阻挡边的点击 */
+  .match-score,
+  .walked-score,
+  .graph-link-label {
+    pointer-events: none;
+  }
+
+  /* 分数标签置灰 */
+  .graph-link-label.dimmed {
+    opacity: 0.15;
   }
   }
 }
 }