浏览代码

fix: 修复激活状态下hover状态清理和恢复问题

- PostTreeView: 简化mouseleave处理器,让watch统一管理高亮状态
- PostTreeView: applyPathHighlightWithEdges先清除残留的高亮类再应用新高亮
- DerivationView: 修复handleNodeHoverOut锁定状态下的路径恢复
- highlight.js: applyHighlight清除残留的highlighted和locked-path类

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 14 小时之前
父节点
当前提交
1187a69f27

+ 88 - 12
script/visualization/src/components/DerivationView.vue

@@ -449,6 +449,70 @@ function computeLocalPath(fromId, toId) {
   return { nodes: pathNodes, edges: pathEdges }
 }
 
+// 基于已高亮的路径计算从 fromId 到 toId 的子路径
+function computePathInHighlighted(fromId, toId, highlightedNodes, highlightedEdges) {
+  // hover 节点必须在已高亮的路径中
+  if (!highlightedNodes.has(fromId)) return { nodes: new Set(), edges: new Set() }
+
+  // 构建邻接表(只包含已高亮的边)
+  const adj = new Map()
+  for (const link of linksData) {
+    const srcId = typeof link.source === 'object' ? link.source.id : link.source
+    const tgtId = typeof link.target === 'object' ? link.target.id : link.target
+    const edgeKey = `${srcId}->${tgtId}`
+    const edgeKeyRev = `${tgtId}->${srcId}`
+
+    // 只使用已高亮的边
+    if (!highlightedEdges.has(edgeKey) && !highlightedEdges.has(edgeKeyRev)) continue
+
+    if (!adj.has(srcId)) adj.set(srcId, [])
+    if (!adj.has(tgtId)) adj.set(tgtId, [])
+    adj.get(srcId).push({ neighbor: tgtId, edge: link })
+    adj.get(tgtId).push({ neighbor: srcId, edge: link })
+  }
+
+  // BFS 找路径
+  const visited = new Set([fromId])
+  const parent = new Map()
+  const parentEdge = new Map()
+  const queue = [fromId]
+
+  while (queue.length > 0) {
+    const curr = queue.shift()
+    if (curr === toId) break
+
+    for (const { neighbor, edge } of (adj.get(curr) || [])) {
+      if (!visited.has(neighbor)) {
+        visited.add(neighbor)
+        parent.set(neighbor, curr)
+        parentEdge.set(neighbor, edge)
+        queue.push(neighbor)
+      }
+    }
+  }
+
+  // 回溯路径
+  const pathNodes = new Set()
+  const pathEdges = new Set()
+
+  if (visited.has(toId)) {
+    let curr = toId
+    while (curr) {
+      pathNodes.add(curr)
+      const edge = parentEdge.get(curr)
+      if (edge) {
+        const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
+        const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
+        pathEdges.add(`${srcId}->${tgtId}`)
+        pathEdges.add(`${tgtId}->${srcId}`)
+      }
+      curr = parent.get(curr)
+    }
+  }
+
+  return { nodes: pathNodes, edges: pathEdges }
+}
+
 // 基于当前显示的边计算从某节点出发的路径(使用统一的 findPaths)
 function computeLocalHoverPath(nodeId) {
   if (!linksData || linksData.length === 0) return { nodes: new Set(), edges: new Set() }
@@ -564,7 +628,7 @@ function handleLockClick() {
   }
 }
 
-// hover 节点处理(使用本地路径计算,基于过滤后的边
+// hover 节点处理(基于当前高亮的路径查找
 function handleNodeHover(event, d) {
   // 只有在有选中节点时才触发路径高亮
   if (!selectedNodeId.value) return
@@ -572,8 +636,16 @@ function handleNodeHover(event, d) {
   // 不处理选中节点自身
   if (d.id === selectedNodeId.value) return
 
-  // 使用本地路径计算(基于当前显示的边)
-  const { nodes: pathNodes, edges: pathEdges } = computeLocalPath(d.id, selectedNodeId.value)
+  // 基于当前高亮的路径查找(只在已高亮的节点和边中查找)
+  const { nodes: pathNodes, edges: pathEdges } = computePathInHighlighted(
+    d.id,
+    selectedNodeId.value,
+    selectedPathNodes.value,
+    selectedPathEdges.value
+  )
+
+  // 如果找不到路径(hover 节点不在高亮路径中),不做任何处理
+  if (pathNodes.size === 0) return
 
   // 更新 store 状态用于联动
   store.hoverNodeId = d.id
@@ -606,23 +678,23 @@ function handleNodeHoverOut() {
 
   store.clearHover()
 
-  // 恢复到选中节点的路径高亮(使用本地路径计算)
-  if (selectedNodeId.value && !store.lockedHoverNodeId) {
-    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
-    store.hoverPathNodes = pathNodes
-    store.hoverPathEdges = pathEdges
+  if (store.lockedHoverNodeId) {
+    // 锁定状态:clearHover 已恢复锁定路径,需要设置 source 并重新高亮
     store.hoverSource = 'derivation'
     applyDerivationHighlight()
-  }
-
-  if (store.lockedHoverNodeId) {
-    // 恢复锁定状态,在锁定节点上显示按钮
+    // 在锁定节点上显示解锁按钮
     nodeSelection.each(function(d) {
       if (d.id === store.lockedHoverNodeId) {
         showLockButton(this, true)
       }
     })
   } else {
+    // 非锁定状态:恢复到选中节点的完整路径高亮
+    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
+    store.hoverPathNodes = pathNodes
+    store.hoverPathEdges = pathEdges
+    store.hoverSource = 'derivation'
+    applyDerivationHighlight()
     hideLockButton()
   }
 }
@@ -721,6 +793,10 @@ function applyDerivationHighlight() {
     }
   }
 
+  // 先清除所有残留的高亮类
+  nodeSelection
+    .classed('highlighted', false)
+
   // 节点高亮
   nodeSelection
     .classed('dimmed', d => !allPathNodes.has(d.id))

+ 144 - 21
script/visualization/src/components/PostTreeView.vue

@@ -1402,6 +1402,69 @@ function handleLockClick() {
   }
 }
 
+// 基于当前高亮的边集合计算从 fromId 到 toId 的路径
+function computePathInHighlightedEdges(fromId, toId) {
+  // 使用当前高亮的边集合
+  const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
+  if (edgeSet.size === 0) return { nodes: new Set(), edges: new Set() }
+
+  // hover 节点必须在高亮节点中
+  if (!store.highlightedNodeIds.has(fromId)) return { nodes: new Set(), edges: new Set() }
+
+  // 构建邻接表(只包含高亮的边)
+  const adj = new Map()
+  for (const edgeKey of edgeSet) {
+    const [srcId, tgtId] = edgeKey.split('->')
+    if (!srcId || !tgtId) continue
+
+    if (!adj.has(srcId)) adj.set(srcId, [])
+    if (!adj.has(tgtId)) adj.set(tgtId, [])
+    adj.get(srcId).push({ neighbor: tgtId, edgeKey })
+    adj.get(tgtId).push({ neighbor: srcId, edgeKey: `${tgtId}->${srcId}` })
+  }
+
+  // BFS 找路径
+  const visited = new Set([fromId])
+  const parent = new Map()
+  const parentEdge = new Map()
+  const queue = [fromId]
+
+  while (queue.length > 0) {
+    const curr = queue.shift()
+    if (curr === toId) break
+
+    for (const { neighbor, edgeKey } of (adj.get(curr) || [])) {
+      if (!visited.has(neighbor)) {
+        visited.add(neighbor)
+        parent.set(neighbor, curr)
+        parentEdge.set(neighbor, edgeKey)
+        queue.push(neighbor)
+      }
+    }
+  }
+
+  // 回溯路径
+  const pathNodes = new Set()
+  const pathEdges = new Set()
+
+  if (visited.has(toId)) {
+    let curr = toId
+    while (curr) {
+      pathNodes.add(curr)
+      const edgeKey = parentEdge.get(curr)
+      if (edgeKey) {
+        pathEdges.add(edgeKey)
+        // 添加反向边
+        const [src, tgt] = edgeKey.split('->')
+        pathEdges.add(`${tgt}->${src}`)
+      }
+      curr = parent.get(curr)
+    }
+  }
+
+  return { nodes: pathNodes, edges: pathEdges }
+}
+
 // 设置 hover 处理器(在所有元素创建后调用)
 function setupHoverHandlers() {
   if (!svgRef.value || !store.selectedNodeId) return
@@ -1418,18 +1481,28 @@ function setupHoverHandlers() {
     }
   }
 
-  // 添加 hover 处理器(路径计算由 store 统一处理
+  // 添加 hover 处理器(基于当前高亮的边集合计算路径
   allNodes
     .on('mouseenter', function(event, d) {
       const nodeId = d.data?.id || d.id
       // 排除起始节点
       if (nodeId === startNodeId) return
 
-      store.computeHoverPath(startNodeId, nodeId, 'post-tree')
-      store.setHoverNode(d)  // 设置 hover 节点数据用于详情显示
+      // 基于当前高亮的边集合计算路径
+      const { nodes: pathNodes, edges: pathEdges } = computePathInHighlightedEdges(nodeId, startNodeId)
+
+      // 如果找不到路径,不做任何处理
+      if (pathNodes.size === 0) return
+
+      // 更新 store 状态
+      store.hoverNodeId = nodeId
+      store.hoverPathNodes = pathNodes
+      store.hoverPathEdges = pathEdges
+      store.hoverSource = 'post-tree'
+      store.setHoverNode(d)
 
       // 显示锁定按钮(在当前hover节点上)
-      if (store.hoverPathNodes.size > 0) {
+      if (pathNodes.size > 0) {
         // 如果已锁定,按钮显示在锁定节点上
         if (store.lockedHoverNodeId) {
           const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
@@ -1446,14 +1519,8 @@ function setupHoverHandlers() {
       // 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
       store.clearHover()
 
+      // 只管理锁定按钮的显示,高亮由 watch 统一处理
       if (store.lockedHoverNodeId) {
-        // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
-        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')
-        // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
-        applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, null)
         // 在锁定节点上显示解锁按钮
         const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
         if (lockedNodeInfo?.element) {
@@ -1705,23 +1772,64 @@ watch(() => store.focusEdgeEndpoints, (endpoints) => {
 
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
-// 监听 hover 状态变化(用于左右联动)
-watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
+// 应用基于边集合的 hover 高亮
+function applyPathHighlightWithEdges(pathNodes, pathEdges) {
   if (!svgRef.value) return
 
-  // 边 hover 时不处理,避免覆盖边 hover 高亮
-  if (isEdgeHovering) 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 高亮(支持嵌套:传入锁定路径)
-    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
-    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
+  // 先清除所有残留的高亮类
+  allNodes
+    .classed('highlighted', false)
+    .classed('selected', false)
+    .classed('locked-path', false)
+  allLinks
+    .classed('highlighted', false)
+    .classed('locked-path', false)
+  allLabels
+    .classed('highlighted', false)
+    .classed('locked-path', false)
+
+  // 节点高亮
+  allNodes.classed('dimmed', function(d) {
+    const nodeId = d.data?.id || d.id
+    return !pathNodes.has(nodeId)
+  })
+
+  // 边高亮(基于边集合判断)
+  allLinks.each(function(d) {
+    const srcId = d.source?.data?.id || d.source?.id || d.source
+    const tgtId = d.target?.data?.id || d.target?.id || d.target
+    const edgeKey = `${srcId}->${tgtId}`
+    const edgeKeyRev = `${tgtId}->${srcId}`
+    const inPath = pathEdges.has(edgeKey) || pathEdges.has(edgeKeyRev)
+    d3.select(this).classed('dimmed', !inPath)
+  })
+
+  // 标签高亮
+  allLabels.each(function(d) {
+    const srcId = d.source?.data?.id || d.source?.id || d.source
+    const tgtId = d.target?.data?.id || d.target?.id || d.target
+    const edgeKey = `${srcId}->${tgtId}`
+    const edgeKeyRev = `${tgtId}->${srcId}`
+    const inPath = pathEdges.has(edgeKey) || pathEdges.has(edgeKeyRev)
+    d3.select(this).classed('dimmed', !inPath)
+  })
+}
+
+// 监听 hover 状态变化(用于左右联动)
+watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
+  if (!svgRef.value) return
+
+  // 边 hover 时不处理,避免覆盖边 hover 高亮
+  if (isEdgeHovering) return
+
+  if (store.hoverPathNodes.size > 0 && store.hoverPathEdges.size > 0) {
+    // 使用边集合来精确判断哪些边应该高亮
+    applyPathHighlightWithEdges(store.hoverPathNodes, store.hoverPathEdges)
 
     // 如果是从 GraphView 触发的,缩放到显示完整路径
     if (store.hoverSource === 'graph') {
@@ -1729,6 +1837,21 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
     }
 
     // 在对应节点上显示锁定按钮(无论来源)
+    if (store.hoverNodeId) {
+      const nodeInfo = nodeElements[store.hoverNodeId]
+      if (nodeInfo?.element) {
+        showLockButton(nodeInfo.element)
+      }
+    }
+  } else if (store.hoverPathNodes.size > 0) {
+    // 兼容旧逻辑(没有边集合时)
+    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')
+    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
+
     if (store.hoverNodeId) {
       const nodeInfo = nodeElements[store.hoverNodeId]
       if (nodeInfo?.element) {

+ 6 - 2
script/visualization/src/utils/highlight.js

@@ -162,8 +162,10 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
   const svg = d3.select(svgEl)
   const hasHighlight = highlightedIds.size > 0
 
-  // 所有节点:在 highlightedIds 中的保持,否则置灰
+  // 所有节点:先清除残留的 hover 相关类,然后应用高亮
   svg.selectAll('.tree-node, .match-node, .graph-node, .walked-node')
+    .classed('highlighted', false)
+    .classed('locked-path', false)
     .classed('selected', d => {
       const nodeId = getNodeId(d)
       if (selectedId) {
@@ -175,8 +177,10 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
     })
     .classed('dimmed', d => hasHighlight && !highlightedIds.has(getNodeId(d)))
 
-  // 所有边:在 walkedEdgeSet 中的保持,否则置灰
+  // 所有边:先清除残留的 hover 相关类,然后应用高亮
   svg.selectAll('.tree-link, .graph-link, .graph-link-label, .match-link, .match-score, .walked-link, .walked-score')
+    .classed('highlighted', false)
+    .classed('locked-path', false)
     .classed('dimmed', function(d) {
       if (!hasHighlight) return false
       if (d && walkedEdgeSet) {