فهرست منبع

feat: hover时显示锁定按钮,点击锁定/解锁路径

- hover节点时在旁边显示📌按钮
- 点击按钮锁定当前hover路径
- 锁定后按钮变为🔓图标,保持显示
- 点击🔓解锁,恢复正常hover行为
- 移除之前的双击锁定逻辑

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 20 ساعت پیش
والد
کامیت
623d29ebb7
2فایلهای تغییر یافته به همراه254 افزوده شده و 54 حذف شده
  1. 115 15
      script/visualization/src/components/GraphView.vue
  2. 139 39
      script/visualization/src/components/PostTreeView.vue

+ 115 - 15
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" @click="handleSvgClick" @dblclick="handleSvgDblClick"></svg>
+      <svg ref="svgRef" class="w-full h-full transition-opacity duration-200" @click="handleSvgClick"></svg>
     </div>
   </div>
 </template>
@@ -324,22 +324,20 @@ 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
       store.computeHoverPath(centerNodeId, d.id, 'graph')
+      // 显示锁定按钮
+      if (store.hoverPathNodes.size > 0) {
+        showLockButton(d.x, d.y, centerNodeId)
+      }
     })
     .on('mouseleave', () => {
       store.clearHover()
+      delayHideLockButton()
     })
 
   // 节点形状(使用统一配置)
@@ -379,21 +377,123 @@ function renderGraph() {
     node.attr('transform', d => `translate(${d.x},${d.y})`)
   })
 
+  // 创建锁定按钮
+  createLockButton()
+
   // 应用初始高亮状态
   nextTick(updateHighlight)
 }
 
-// 单击空白取消游走
-function handleSvgClick(event) {
-  if (event.target.tagName === 'svg') {
-    store.clearSelection()
+// 锁定按钮相关
+let lockButtonG = null
+let hideButtonTimer = null
+
+// 创建锁定按钮
+function createLockButton() {
+  if (!graphG) return
+
+  // 移除旧按钮
+  graphG.selectAll('.lock-button').remove()
+
+  // 创建按钮组
+  lockButtonG = graphG.append('g')
+    .attr('class', 'lock-button')
+    .style('display', 'none')
+    .style('cursor', 'pointer')
+    .on('click', (e) => {
+      e.stopPropagation()
+      if (store.lockedHoverNodeId) {
+        // 已锁定,点击解锁
+        store.clearLockedHover()
+        hideLockButton()
+      } else if (currentStartNodeId) {
+        // 未锁定,点击锁定
+        store.lockCurrentHover(currentStartNodeId)
+        updateLockButtonIcon(true)
+      }
+    })
+    .on('mouseenter', () => {
+      if (hideButtonTimer) {
+        clearTimeout(hideButtonTimer)
+        hideButtonTimer = null
+      }
+    })
+    .on('mouseleave', () => {
+      if (!store.lockedHoverNodeId) {
+        hideLockButton()
+      }
+    })
+
+  // 按钮背景
+  lockButtonG.append('circle')
+    .attr('r', 10)
+    .attr('fill', '#2d3748')
+    .attr('stroke', '#4a5568')
+    .attr('stroke-width', 1)
+
+  // 锁定图标
+  lockButtonG.append('text')
+    .attr('class', 'lock-icon')
+    .attr('text-anchor', 'middle')
+    .attr('dy', '0.35em')
+    .attr('font-size', '10px')
+    .attr('fill', '#fff')
+    .text('📌')
+}
+
+// 更新锁定按钮图标
+function updateLockButtonIcon(isLocked) {
+  if (!lockButtonG) return
+  lockButtonG.select('.lock-icon').text(isLocked ? '🔓' : '📌')
+}
+
+// 当前起始节点ID(用于锁定按钮)
+let currentStartNodeId = null
+
+// 显示锁定按钮
+function showLockButton(x, y, startNodeId) {
+  if (!lockButtonG) return
+  currentStartNodeId = startNodeId
+
+  if (hideButtonTimer) {
+    clearTimeout(hideButtonTimer)
+    hideButtonTimer = null
+  }
+
+  // 更新图标状态
+  updateLockButtonIcon(!!store.lockedHoverNodeId)
+
+  lockButtonG
+    .attr('transform', `translate(${x + 15}, ${y - 15})`)
+    .style('display', 'block')
+}
+
+// 隐藏锁定按钮
+function hideLockButton() {
+  if (!lockButtonG) return
+  lockButtonG.style('display', 'none')
+}
+
+// 延迟隐藏锁定按钮
+function delayHideLockButton() {
+  // 如果已锁定,不隐藏
+  if (store.lockedHoverNodeId) return
+
+  if (hideButtonTimer) {
+    clearTimeout(hideButtonTimer)
   }
+  hideButtonTimer = setTimeout(() => {
+    if (!store.lockedHoverNodeId) {
+      hideLockButton()
+    }
+    hideButtonTimer = null
+  }, 200)
 }
 
-// 双击空白取消hover锁定
-function handleSvgDblClick(event) {
+// 点击空白取消
+function handleSvgClick(event) {
   if (event.target.tagName === 'svg') {
-    store.clearLockedHover()
+    store.clearSelection()
   }
 }
 

+ 139 - 39
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" @dblclick="handleSvgDblClick"></svg>
+        <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
       </div>
     </div>
 
@@ -350,21 +350,12 @@ 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)
@@ -464,7 +455,6 @@ function renderTree() {
     .attr('transform', d => `translate(${d.x},${d.y})`)
     .style('cursor', 'pointer')
     .on('click', handleNodeClick)
-    .on('dblclick', handleNodeDblClick)
 
   // 节点形状(使用统一配置)
   nodes.each(function(d) {
@@ -642,7 +632,6 @@ 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) {
@@ -911,13 +900,6 @@ 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) {
@@ -949,6 +931,112 @@ function renderWalkedLayer() {
   setupHoverHandlers()
 }
 
+// 锁定按钮相关
+let lockButtonG = null
+let hideButtonTimer = null
+
+// 创建锁定按钮(在主组中创建一次)
+function createLockButton() {
+  if (!mainG) return
+
+  // 移除旧按钮
+  mainG.selectAll('.lock-button').remove()
+
+  // 创建按钮组
+  lockButtonG = mainG.append('g')
+    .attr('class', 'lock-button')
+    .style('display', 'none')
+    .style('cursor', 'pointer')
+    .on('click', (e) => {
+      e.stopPropagation()
+      const startNodeId = store.selectedNodeId
+      if (store.lockedHoverNodeId) {
+        // 已锁定,点击解锁
+        store.clearLockedHover()
+        hideLockButton()
+      } else if (startNodeId) {
+        // 未锁定,点击锁定
+        store.lockCurrentHover(startNodeId)
+        updateLockButtonIcon(true)
+      }
+    })
+    .on('mouseenter', () => {
+      // 鼠标进入按钮,取消隐藏定时器
+      if (hideButtonTimer) {
+        clearTimeout(hideButtonTimer)
+        hideButtonTimer = null
+      }
+    })
+    .on('mouseleave', () => {
+      // 鼠标离开按钮,如果未锁定则隐藏
+      if (!store.lockedHoverNodeId) {
+        hideLockButton()
+      }
+    })
+
+  // 按钮背景
+  lockButtonG.append('circle')
+    .attr('r', 10)
+    .attr('fill', '#2d3748')
+    .attr('stroke', '#4a5568')
+    .attr('stroke-width', 1)
+
+  // 锁定图标
+  lockButtonG.append('text')
+    .attr('class', 'lock-icon')
+    .attr('text-anchor', 'middle')
+    .attr('dy', '0.35em')
+    .attr('font-size', '10px')
+    .attr('fill', '#fff')
+    .text('📌')
+}
+
+// 更新锁定按钮图标
+function updateLockButtonIcon(isLocked) {
+  if (!lockButtonG) return
+  lockButtonG.select('.lock-icon').text(isLocked ? '🔓' : '📌')
+}
+
+// 显示锁定按钮
+function showLockButton(x, y) {
+  if (!lockButtonG) return
+
+  // 取消隐藏定时器
+  if (hideButtonTimer) {
+    clearTimeout(hideButtonTimer)
+    hideButtonTimer = null
+  }
+
+  // 更新图标状态
+  updateLockButtonIcon(!!store.lockedHoverNodeId)
+
+  lockButtonG
+    .attr('transform', `translate(${x + 15}, ${y - 15})`)
+    .style('display', 'block')
+}
+
+// 隐藏锁定按钮
+function hideLockButton() {
+  if (!lockButtonG) return
+  lockButtonG.style('display', 'none')
+}
+
+// 延迟隐藏锁定按钮(给用户移动到按钮的时间)
+function delayHideLockButton() {
+  // 如果已锁定,不隐藏
+  if (store.lockedHoverNodeId) return
+
+  if (hideButtonTimer) {
+    clearTimeout(hideButtonTimer)
+  }
+  hideButtonTimer = setTimeout(() => {
+    if (!store.lockedHoverNodeId) {
+      hideLockButton()
+    }
+    hideButtonTimer = null
+  }, 200)
+}
+
 // 设置 hover 处理器(在所有元素创建后调用)
 function setupHoverHandlers() {
   if (!svgRef.value || !store.selectedNodeId) return
@@ -957,32 +1045,52 @@ function setupHoverHandlers() {
   const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
   const startNodeId = store.selectedNodeId
 
+  // 创建锁定按钮
+  createLockButton()
+
+  // 如果已有锁定状态,显示解锁按钮
+  if (store.lockedHoverNodeId) {
+    const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
+    if (lockedNodeInfo) {
+      showLockButton(lockedNodeInfo.x - 50, lockedNodeInfo.y - 25)
+    }
+  }
+
   // 添加 hover 处理器(路径计算由 store 统一处理)
   allNodes
     .on('mouseenter', (event, d) => {
       const nodeId = d.data?.id || d.id
+      // 排除起始节点
+      if (nodeId === startNodeId) return
+
       store.computeHoverPath(startNodeId, nodeId, 'post-tree')
+
+      // 显示锁定按钮
+      // 如果已锁定,按钮保持在锁定节点位置;否则跟随hover节点
+      if (store.lockedHoverNodeId) {
+        const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
+        if (lockedNodeInfo) {
+          showLockButton(lockedNodeInfo.x - 50, lockedNodeInfo.y - 25)
+        }
+      } else {
+        const nodeInfo = nodeElements[nodeId]
+        if (nodeInfo && store.hoverPathNodes.size > 0) {
+          showLockButton(nodeInfo.x - 50, nodeInfo.y - 25)
+        }
+      }
     })
     .on('mouseleave', () => {
       store.clearHover()
+      delayHideLockButton()
     })
 }
 
-// 匹配节点单击处理(触发游走)
+// 匹配节点点击处理
 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 -> 中文/可读)
@@ -1130,7 +1238,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')) {
@@ -1138,14 +1246,6 @@ 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()