瀏覽代碼

refactor: 优化hover联动,路径计算集中到store

- store新增computeHoverPath统一计算hover路径
- 基于共享的walkedEdgeSet/postWalkedEdgeSet计算
- GraphView和PostTreeView简化为只触发和监听
- 两边hover效果自动同步联动

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 20 小時之前
父節點
當前提交
7a4364d887

+ 29 - 7
script/visualization/src/components/GraphView.vue

@@ -107,7 +107,7 @@ import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
 import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
-import { applyHighlight, findPath, applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
+import { applyHighlight, applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
   collapsed: { type: Boolean, default: false },
@@ -141,6 +141,12 @@ function resetMiddleEdgeTypes() {
 
 let simulation = null
 
+// 用于 hover 联动的选择集和数据
+let graphNodeSelection = null
+let graphLinkSelection = null
+let graphLinkLabelSelection = null
+let graphLinksData = []
+
 // 游走配置操作(直接操作 store)
 function selectAllEdgeTypes(stepIndex) {
   store.stepConfigs[stepIndex].edgeTypes = [...store.allEdgeTypes]
@@ -318,14 +324,11 @@ function renderGraph() {
     })
     .on('mouseenter', (e, d) => {
       if (d.isCenter) return  // 中心节点不处理
-      // 找从中心到 hover 节点的路径
-      const pathNodes = findPath(centerNodeId, d.id, links)
-      if (pathNodes.size > 0) {
-        applyHoverHighlight(node, link, linkLabel, pathNodes)
-      }
+      // 路径计算由 store 统一处理
+      store.computeHoverPath(centerNodeId, d.id)
     })
     .on('mouseleave', () => {
-      clearHoverHighlight(node, link, linkLabel)
+      store.clearHover()
     })
 
   // 节点形状(使用统一配置)
@@ -341,6 +344,12 @@ function renderGraph() {
     .attr('text-anchor', 'middle')
     .text(d => d.name.length > 8 ? d.name.slice(0, 8) + '...' : d.name)
 
+  // 保存选择集和数据(用于 hover 联动)
+  graphNodeSelection = node
+  graphLinkSelection = link
+  graphLinkLabelSelection = linkLabel
+  graphLinksData = links
+
   // 更新位置
   simulation.on('tick', () => {
     link
@@ -390,6 +399,19 @@ watch(() => store.selectedEdgeId, () => {
 // 监听高亮节点集合变化
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
+// 监听 hover 状态变化(用于左右联动)
+watch(() => store.hoverPathNodes.size, () => {
+  if (!graphNodeSelection || !graphLinkSelection) return
+
+  if (store.hoverPathNodes.size > 0) {
+    // 应用 hover 高亮
+    applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes)
+  } else {
+    // 清除 hover,恢复原有高亮
+    clearHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection)
+  }
+})
+
 // 监听配置变化,重新选中触发游走
 watch([() => store.walkSteps, () => store.stepConfigs], () => {
   if (store.selectedNodeId && isPersonaWalk.value) {

+ 22 - 35
script/visualization/src/components/PostTreeView.vue

@@ -196,7 +196,7 @@ import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 import { getNodeStyle, applyNodeShape, dimColors } from '../config/nodeStyle'
 import { getEdgeStyle, edgeTypeColors } from '../config/edgeStyle'
-import { applyHighlight, findPath, applyHoverHighlight } from '../utils/highlight'
+import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
   showExpand: {
@@ -925,48 +925,17 @@ function setupHoverHandlers() {
   if (!svgRef.value || !store.selectedNodeId) return
 
   const svg = d3.select(svgRef.value)
-
-  // 选择所有节点和边(使用 SVG 级别选择器)
   const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
-  const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
-  const allLabels = svg.selectAll('.match-score, .walked-score')
-
-  // 获取起始节点(当前选中的节点)
   const startNodeId = store.selectedNodeId
 
-  // 获取高亮的边集合(只在高亮的子图中查找路径)
-  const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
-  if (edgeSet.size === 0) return  // 没有高亮的边,不需要 hover 效果
-
-  // 将边集合转换为 findPath 需要的格式
-  const highlightedLinks = []
-  for (const edgeKey of edgeSet) {
-    // edgeKey 格式: "sourceId->targetId"
-    const [source, target] = edgeKey.split('->')
-    if (source && target) {
-      highlightedLinks.push({ source, target })
-    }
-  }
-
-  // 添加 hover 处理器
+  // 添加 hover 处理器(路径计算由 store 统一处理)
   allNodes
     .on('mouseenter', (event, d) => {
-      // 获取节点 ID
       const nodeId = d.data?.id || d.id
-      if (nodeId === startNodeId) return  // 起点不需要高亮路径
-
-      // 只在高亮的节点中才响应 hover
-      if (!store.highlightedNodeIds.has(nodeId)) return
-
-      // 计算从起点到 hover 节点的路径(只在高亮的边中查找)
-      const pathNodes = findPath(startNodeId, nodeId, highlightedLinks)
-      if (pathNodes.size > 0) {
-        applyHoverHighlight(allNodes, allLinks, allLabels, pathNodes)
-      }
+      store.computeHoverPath(startNodeId, nodeId)
     })
     .on('mouseleave', () => {
-      // 恢复之前的高亮状态,而不是全部变亮
-      updateHighlight()
+      store.clearHover()
     })
 }
 
@@ -1154,6 +1123,24 @@ watch(() => store.focusEdgeEndpoints, (endpoints) => {
 
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
+// 监听 hover 状态变化(用于左右联动)
+watch(() => store.hoverPathNodes.size, () => {
+  if (!svgRef.value) return
+  const svg = d3.select(svgRef.value)
+
+  const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
+  const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
+  const allLabels = svg.selectAll('.match-score, .walked-score')
+
+  if (store.hoverPathNodes.size > 0) {
+    // 应用 hover 高亮
+    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes)
+  } else {
+    // 清除 hover,恢复原有高亮
+    updateHighlight()
+  }
+})
+
 // 监听帖子游走结果变化,渲染游走层
 watch(() => store.postWalkedNodes.length, () => {
   nextTick(renderWalkedLayer)

+ 76 - 0
script/visualization/src/stores/graph.js

@@ -649,6 +649,77 @@ export const useGraphStore = defineStore('graph', () => {
     postWalkedEdges.value = []
     focusNodeId.value = null
     focusEdgeEndpoints.value = null
+    clearHover()
+  }
+
+  // ==================== Hover 状态(左右联动) ====================
+  const hoverNodeId = ref(null)  // 当前 hover 的节点 ID
+  const hoverPathNodes = ref(new Set())  // hover 路径上的节点集合
+
+  // 计算 hover 路径(基于已高亮的边)
+  function computeHoverPath(startId, endId) {
+    if (!startId || !endId || startId === endId) {
+      clearHover()
+      return
+    }
+    // 只在高亮的节点中才响应
+    if (!highlightedNodeIds.value.has(endId)) {
+      return
+    }
+
+    // 获取高亮的边集合
+    const edgeSet = postWalkedEdgeSet.value.size > 0 ? postWalkedEdgeSet.value : walkedEdgeSet.value
+    if (edgeSet.size === 0) return
+
+    // 将边集合转换为邻接表
+    const adj = new Map()
+    for (const edgeKey of edgeSet) {
+      const [src, tgt] = edgeKey.split('->')
+      if (src && tgt) {
+        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)
+      }
+    }
+
+    if (pathNodes.size > 0) {
+      hoverNodeId.value = endId
+      hoverPathNodes.value = pathNodes
+    }
+  }
+
+  // 清除 hover 状态
+  function clearHover() {
+    hoverNodeId.value = null
+    hoverPathNodes.value = new Set()
   }
 
   // 计算属性:当前选中节点的数据
@@ -718,6 +789,11 @@ export const useGraphStore = defineStore('graph', () => {
     selectNode,
     selectEdge,
     clearSelection,
+    // Hover 联动
+    hoverNodeId,
+    hoverPathNodes,
+    computeHoverPath,
+    clearHover,
     // 布局
     expandedPanel,
     expandPanel,