瀏覽代碼

feat: 为待解构帖子添加hover高亮路径效果

- 提取公共hover工具函数到highlight.js (findPath, applyHoverHighlight)
- GraphView复用公共函数
- PostTreeView基于高亮子图实现hover路径效果
- mouseleave时恢复原有高亮状态

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

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

+ 3 - 59
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 } from '../utils/highlight'
+import { applyHighlight, findPath, applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
   collapsed: { type: Boolean, default: false },
@@ -165,48 +165,6 @@ const currentNodeName = computed(() => {
 const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(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() {
@@ -363,25 +321,11 @@ function renderGraph() {
       // 找从中心到 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)
-        })
+        applyHoverHighlight(node, link, linkLabel, pathNodes)
       }
     })
     .on('mouseleave', () => {
-      // 恢复所有节点和边
-      node.classed('dimmed', false)
-      link.classed('dimmed', false)
-      linkLabel.classed('dimmed', false)
+      clearHoverHighlight(node, link, linkLabel)
     })
 
   // 节点形状(使用统一配置)

+ 59 - 1
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 } from '../utils/highlight'
+import { applyHighlight, findPath, applyHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
   showExpand: {
@@ -474,6 +474,9 @@ function renderTree() {
 
   // 初始适应视图
   fitToView()
+
+  // 设置 hover 处理器(需要在所有元素创建后)
+  nextTick(() => setupHoverHandlers())
 }
 
 // 绘制匹配层(人设节点 + 连线)
@@ -912,6 +915,59 @@ function renderWalkedLayer() {
     const maxY = Math.max(...Object.values(nodePositions).map(p => p.y))
     treeHeight = Math.max(treeHeight, maxY + 50)
   }
+
+  // ========== 添加 hover 效果(所有元素创建后统一添加) ==========
+  setupHoverHandlers()
+}
+
+// 设置 hover 处理器(在所有元素创建后调用)
+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 处理器
+  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)
+      }
+    })
+    .on('mouseleave', () => {
+      // 恢复之前的高亮状态,而不是全部变亮
+      updateHighlight()
+    })
 }
 
 // 匹配节点点击处理
@@ -1081,6 +1137,8 @@ watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
   if (nodeId && nodeId !== oldNodeId) {
     zoomToNode(nodeId)
   }
+  // 重新设置 hover 处理器(起点改变了)
+  nextTick(() => setupHoverHandlers())
 })
 
 watch(() => store.selectedEdgeId, updateHighlight)

+ 87 - 16
script/visualization/src/utils/highlight.js

@@ -4,17 +4,17 @@ import * as d3 from 'd3'
 /**
  * 获取节点ID(兼容 d3.hierarchy 和普通对象)
  */
-function getNodeId(d) {
+export function getNodeId(d) {
   return d.data?.id || d.id
 }
 
 /**
  * 获取边的 source 和 target ID
  */
-function getEdgeIds(d) {
-  const sourceId = d.source?.data?.id || d.source?.id || d.source
-  const targetId = d.target?.data?.id || d.target?.id || d.target
-  return { sourceId, targetId }
+export function getLinkIds(link) {
+  const srcId = link.source?.id || link.source?.data?.id || link.source
+  const tgtId = link.target?.id || link.target?.data?.id || link.target
+  return { srcId, tgtId }
 }
 
 /**
@@ -22,9 +22,86 @@ function getEdgeIds(d) {
  */
 function isEdgeInWalked(d, walkedEdgeSet) {
   if (!walkedEdgeSet) return false
-  const { sourceId, targetId } = getEdgeIds(d)
-  // 检查正向和反向
-  return walkedEdgeSet.has(`${sourceId}->${targetId}`) || walkedEdgeSet.has(`${targetId}->${sourceId}`)
+  const { srcId, tgtId } = getLinkIds(d)
+  return walkedEdgeSet.has(`${srcId}->${tgtId}`) || walkedEdgeSet.has(`${tgtId}->${srcId}`)
+}
+
+/**
+ * BFS 找从 start 到 end 的路径,返回路径上所有节点 ID 的 Set
+ * @param {string} startId - 起点节点ID
+ * @param {string} endId - 终点节点ID
+ * @param {Array} links - 边数组,每个元素需要有 source 和 target
+ */
+export function findPath(startId, endId, links) {
+  // 构建邻接表(无向)
+  const adj = new Map()
+  for (const link of links) {
+    const { srcId, tgtId } = getLinkIds(link)
+    if (!adj.has(srcId)) adj.set(srcId, [])
+    if (!adj.has(tgtId)) adj.set(tgtId, [])
+    adj.get(srcId).push(tgtId)
+    adj.get(tgtId).push(srcId)
+  }
+
+  // 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
+}
+
+/**
+ * 应用 hover 效果:高亮从起点到 hover 节点的路径
+ * @param {D3Selection} nodeSelection - 节点选择集
+ * @param {D3Selection} linkSelection - 边选择集
+ * @param {D3Selection} labelSelection - 标签选择集(可选)
+ * @param {Set} pathNodes - 路径上的节点ID集合
+ */
+export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection, pathNodes) {
+  nodeSelection.classed('dimmed', d => !pathNodes.has(getNodeId(d)))
+  linkSelection.classed('dimmed', l => {
+    const { srcId, tgtId } = getLinkIds(l)
+    return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
+  })
+  if (labelSelection) {
+    labelSelection.classed('dimmed', l => {
+      const { srcId, tgtId } = getLinkIds(l)
+      return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
+    })
+  }
+}
+
+/**
+ * 清除 hover 效果
+ */
+export function clearHoverHighlight(nodeSelection, linkSelection, labelSelection) {
+  nodeSelection.classed('dimmed', false)
+  linkSelection.classed('dimmed', false)
+  if (labelSelection) {
+    labelSelection.classed('dimmed', false)
+  }
 }
 
 /**
@@ -41,17 +118,12 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
   const hasHighlight = highlightedIds.size > 0
 
   // 所有节点:在 highlightedIds 中的保持,否则置灰
-  // selected 逻辑:
-  // - 点击节点时(selectedId 有值):只有 selectedId 是 selected
-  // - 点击边时(selectedId 为空但 highlightedIds 有值):highlightedIds 中的节点(边的两端)都是 selected
   svg.selectAll('.tree-node, .match-node, .graph-node, .walked-node')
     .classed('selected', d => {
       const nodeId = getNodeId(d)
       if (selectedId) {
-        // 点击节点:只有被点击的节点是 selected
         return nodeId === selectedId
       } else if (hasHighlight) {
-        // 点击边:边的两端节点都是 selected
         return highlightedIds.has(nodeId)
       }
       return false
@@ -65,10 +137,9 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
       if (d && walkedEdgeSet) {
         return !isEdgeInWalked(d, walkedEdgeSet)
       }
-      // fallback:检查两端节点是否都高亮
       if (d) {
-        const { sourceId, targetId } = getEdgeIds(d)
-        return !highlightedIds.has(sourceId) || !highlightedIds.has(targetId)
+        const { srcId, tgtId } = getLinkIds(d)
+        return !highlightedIds.has(srcId) || !highlightedIds.has(tgtId)
       }
       return true
     })