Bladeren bron

feat: 添加hover效果锁定功能(双击锁定)

交互设计:
- 单击节点:触发游走算法(保持原有功能)
- 双击节点:锁定当前hover路径
- 单击空白:清除游走
- 双击空白:清除hover锁定

锁定后hover其他节点会临时显示新路径,移开后恢复锁定的路径

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 22 uur geleden
bovenliggende
commit
2e0ca795bb

+ 16 - 2
script/visualization/src/components/GraphView.vue

@@ -96,7 +96,7 @@
 
     <!-- SVG 容器 -->
     <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" @click="handleSvgClick" @dblclick="handleSvgDblClick"></svg>
     </div>
   </div>
 </template>
@@ -324,8 +324,15 @@ function renderGraph() {
       }))
     .on('click', (e, d) => {
       e.stopPropagation()
+      // 单击触发游走算法
       store.selectNode(d.id)
     })
+    .on('dblclick', (e, d) => {
+      e.stopPropagation()
+      if (d.isCenter) return  // 中心节点不处理
+      // 双击锁定当前 hover 状态
+      store.lockCurrentHover(centerNodeId)
+    })
     .on('mouseenter', (e, d) => {
       if (d.isCenter) return  // 中心节点不处理
       // 路径计算由 store 统一处理,标记来源为 graph
@@ -376,13 +383,20 @@ function renderGraph() {
   nextTick(updateHighlight)
 }
 
-// 点击空白取消激活
+// 单击空白取消游走
 function handleSvgClick(event) {
   if (event.target.tagName === 'svg') {
     store.clearSelection()
   }
 }
 
+// 双击空白取消hover锁定
+function handleSvgDblClick(event) {
+  if (event.target.tagName === 'svg') {
+    store.clearLockedHover()
+  }
+}
+
 // 统一高亮更新
 function updateHighlight() {
   const edgeSet = store.walkedEdgeSet.size > 0 ? store.walkedEdgeSet : store.postWalkedEdgeSet

+ 39 - 4
script/visualization/src/components/PostTreeView.vue

@@ -43,7 +43,7 @@
 
       <!-- SVG 容器 -->
       <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
-        <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
+        <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick" @dblclick="handleSvgDblClick"></svg>
       </div>
     </div>
 
@@ -350,12 +350,21 @@ let nodeElements = {}
 let baseNodeElements = {}  // 基础节点(帖子树+匹配层),不含游走节点
 let currentRoot = null
 
-// 处理节点点击
+// 处理节点单击(触发游走)
 function handleNodeClick(event, d) {
   event.stopPropagation()
   store.selectNode(d)
 }
 
+// 处理节点双击(锁定hover)
+function handleNodeDblClick(event, d) {
+  event.stopPropagation()
+  const startNodeId = store.selectedNodeId
+  if (startNodeId) {
+    store.lockCurrentHover(startNodeId)
+  }
+}
+
 // 渲染树
 function renderTree() {
   const svg = d3.select(svgRef.value)
@@ -455,6 +464,7 @@ function renderTree() {
     .attr('transform', d => `translate(${d.x},${d.y})`)
     .style('cursor', 'pointer')
     .on('click', handleNodeClick)
+    .on('dblclick', handleNodeDblClick)
 
   // 节点形状(使用统一配置)
   nodes.each(function(d) {
@@ -632,6 +642,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     .attr('transform', d => `translate(${d.x},${d.y})`)
     .style('cursor', 'pointer')
     .on('click', handleMatchNodeClick)
+    .on('dblclick', handleMatchNodeDblClick)
 
   // 匹配节点形状(使用统一配置)
   matchNodes.each(function(d) {
@@ -900,6 +911,13 @@ function renderWalkedLayer() {
       event.stopPropagation()
       store.selectNode(d)
     })
+    .on('dblclick', (event, d) => {
+      event.stopPropagation()
+      const startNodeId = store.selectedNodeId
+      if (startNodeId) {
+        store.lockCurrentHover(startNodeId)
+      }
+    })
 
   // 节点形状
   walkedNodeGroups.each(function(d) {
@@ -950,12 +968,21 @@ function setupHoverHandlers() {
     })
 }
 
-// 匹配节点点击处理
+// 匹配节点单击处理(触发游走)
 function handleMatchNodeClick(event, d) {
   event.stopPropagation()
   store.selectNode(d)
 }
 
+// 匹配节点双击处理(锁定hover)
+function handleMatchNodeDblClick(event, d) {
+  event.stopPropagation()
+  const startNodeId = store.selectedNodeId
+  if (startNodeId) {
+    store.lockCurrentHover(startNodeId)
+  }
+}
+
 // ========== 详情显示格式化函数 ==========
 
 // 格式化字段名(camelCase/snake_case -> 中文/可读)
@@ -1103,7 +1130,7 @@ function updateHighlight() {
   applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
 }
 
-// 点击空白取消激活
+// 单击空白取消游走
 function handleSvgClick(event) {
   const target = event.target
   if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
@@ -1111,6 +1138,14 @@ function handleSvgClick(event) {
   }
 }
 
+// 双击空白取消hover锁定
+function handleSvgDblClick(event) {
+  const target = event.target
+  if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
+    store.clearLockedHover()
+  }
+}
+
 // 监听选中/高亮变化,统一更新
 watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
   updateHighlight()

+ 53 - 1
script/visualization/src/stores/graph.js

@@ -657,6 +657,11 @@ export const useGraphStore = defineStore('graph', () => {
   const hoverPathNodes = ref(new Set())  // hover 路径上的节点集合
   const hoverSource = ref(null)  // hover 来源: 'graph' | 'post-tree'
 
+  // 锁定的 hover 状态
+  const lockedHoverNodeId = ref(null)
+  const lockedHoverPathNodes = ref(new Set())
+  const lockedHoverStartId = ref(null)  // 锁定时的起点
+
   // 计算 hover 路径(基于已高亮的边)
   // source: 触发 hover 的模块标识
   function computeHoverPath(startId, endId, source = null) {
@@ -719,13 +724,55 @@ export const useGraphStore = defineStore('graph', () => {
     }
   }
 
-  // 清除 hover 状态
+  // 清除 hover 状态(如果有锁定则恢复到锁定状态)
   function clearHover() {
+    if (lockedHoverNodeId.value) {
+      // 恢复到锁定状态
+      hoverNodeId.value = lockedHoverNodeId.value
+      hoverPathNodes.value = new Set(lockedHoverPathNodes.value)
+      hoverSource.value = null
+    } else {
+      hoverNodeId.value = null
+      hoverPathNodes.value = new Set()
+      hoverSource.value = null
+    }
+  }
+
+  // 锁定当前 hover 状态(单击节点时调用)
+  function lockCurrentHover(startId) {
+    if (hoverNodeId.value && hoverPathNodes.value.size > 0) {
+      lockedHoverNodeId.value = hoverNodeId.value
+      lockedHoverPathNodes.value = new Set(hoverPathNodes.value)
+      lockedHoverStartId.value = startId
+    }
+  }
+
+  // 清除锁定的 hover 状态(单击空白时调用)
+  function clearLockedHover() {
+    lockedHoverNodeId.value = null
+    lockedHoverPathNodes.value = new Set()
+    lockedHoverStartId.value = null
+    // 同时清除当前 hover
     hoverNodeId.value = null
     hoverPathNodes.value = new Set()
     hoverSource.value = null
   }
 
+  // 清除游走结果(双击空白时调用,保留hover锁定)
+  function clearWalk() {
+    selectedNodeId.value = null
+    selectedEdgeId.value = null
+    highlightedNodeIds.value = new Set()
+    walkedEdges.value = []
+    postWalkedPaths.value = []
+    postWalkedNodes.value = []
+    postWalkedEdges.value = []
+    focusNodeId.value = null
+    focusEdgeEndpoints.value = null
+    // 同时清除锁定的 hover(因为游走结果没了,hover路径也没意义了)
+    clearLockedHover()
+  }
+
   // 计算属性:当前选中节点的数据
   const selectedNode = computed(() => {
     return selectedNodeId.value ? getNode(selectedNodeId.value) : null
@@ -803,8 +850,13 @@ export const useGraphStore = defineStore('graph', () => {
     hoverNodeId,
     hoverPathNodes,
     hoverSource,
+    lockedHoverNodeId,
+    lockedHoverPathNodes,
     computeHoverPath,
     clearHover,
+    lockCurrentHover,
+    clearLockedHover,
+    clearWalk,
     // 布局
     expandedPanel,
     expandPanel,