Browse Source

feat: 帖子树改为缩放模式,智能调整间距

- 使用 d3.zoom 实现拖拽和滚轮缩放
- 根据文字长度动态计算节点间距,避免重叠
- 初始自动适应视图显示全部内容
- 修复点击空白区域取消激活

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 1 ngày trước cách đây
mục cha
commit
bbfbf0b17c
1 tập tin đã thay đổi với 136 bổ sung50 xóa
  1. 136 50
      script/visualization/src/components/PostTreeView.vue

+ 136 - 50
script/visualization/src/components/PostTreeView.vue

@@ -43,14 +43,14 @@
     </div>
 
     <!-- SVG 容器 -->
-    <div ref="containerRef" class="flex-1 overflow-auto bg-base-100">
-      <svg ref="svgRef" class="block" @click="handleSvgClick"></svg>
+    <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
+      <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 
@@ -69,6 +69,12 @@ const svgRef = ref(null)
 // 当前选中的帖子索引
 const selectedPostIdx = ref(store.selectedPostIndex)
 
+// zoom 实例和主 g 元素
+let zoom = null
+let mainG = null
+let treeWidth = 0
+let treeHeight = 0
+
 // 帖子选择变化
 function onPostChange() {
   store.selectPost(selectedPostIdx.value)
@@ -117,6 +123,14 @@ function renderTree() {
   const treeData = store.postTreeData
   if (!treeData || !treeData.id) return
 
+  const container = containerRef.value
+  if (!container) return
+
+  const width = container.clientWidth
+  const height = container.clientHeight
+
+  svg.attr('viewBox', `0 0 ${width} ${height}`)
+
   // 创建层级数据
   const root = d3.hierarchy(treeData)
   currentRoot = root
@@ -126,26 +140,55 @@ function renderTree() {
   const maxDepth = d3.max(allNodes, d => d.depth)
   const leafCount = allNodes.filter(d => !d.children).length
 
-  // 宽度:基于叶子节点数量(垂直布局时叶子横向展开)
-  const treeWidth = Math.max(400, leafCount * 80 + 100)
-  // 高度:根据深度
-  const treeHeight = Math.max(400, (maxDepth + 1) * 100 + 50)
+  // 计算最长文字长度(用于动态调整间距)
+  const maxTextLen = d3.max(allNodes, d => {
+    const name = d.data.name || ''
+    return Math.min(name.length, 10) // 最多显示10个字
+  }) || 6
+
+  // 动态计算节点间距(根据文字长度)
+  const nodeSpacing = Math.max(60, maxTextLen * 12)
+
+  // 宽度:基于叶子节点数量和文字间距
+  treeWidth = Math.max(400, leafCount * nodeSpacing + 100)
+  // 高度:根据深度,增大层间距避免垂直重叠
+  treeHeight = Math.max(400, (maxDepth + 1) * 120 + 50)
+
+  // 创建缩放行为
+  zoom = d3.zoom()
+    .scaleExtent([0.1, 3])
+    .on('zoom', (e) => {
+      mainG.attr('transform', e.transform)
+    })
+
+  svg.call(zoom)
 
-  svg.attr('width', treeWidth).attr('height', treeHeight + 50)
+  // 创建主组
+  mainG = svg.append('g')
 
   // 创建树布局(垂直方向:从上到下)
   const treeLayout = d3.tree()
     .size([treeWidth - 100, treeHeight - 50])
-    .separation((a, b) => a.parent === b.parent ? 1 : 1.5)
+    .separation((a, b) => {
+      // 同级节点间距根据是否有子节点调整
+      if (a.parent === b.parent) {
+        // 叶子节点需要更大间距放文字
+        const aIsLeaf = !a.children
+        const bIsLeaf = !b.children
+        if (aIsLeaf || bIsLeaf) return 1.5
+        return 1
+      }
+      return 2
+    })
 
   treeLayout(root)
 
-  // 创建主组
-  const g = svg.append('g')
+  // 内容组(带偏移)
+  const contentG = mainG.append('g')
     .attr('transform', 'translate(50, 25)')
 
   // 绘制边(垂直方向)
-  g.append('g')
+  contentG.append('g')
     .attr('class', 'tree-edges')
     .selectAll('.tree-link')
     .data(root.links())
@@ -161,7 +204,7 @@ function renderTree() {
     })
 
   // 绘制节点(垂直布局:x 是水平位置,y 是垂直位置)
-  const nodes = g.append('g')
+  const nodes = contentG.append('g')
     .attr('class', 'tree-nodes')
     .selectAll('.tree-node')
     .data(root.descendants())
@@ -206,7 +249,7 @@ function renderTree() {
         .attr('stroke-width', 1)
     }
 
-    nodeElements[d.data.id] = this
+    nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
 
   // 节点标签(垂直布局:标签在节点下方或右侧)
@@ -234,25 +277,51 @@ function renderTree() {
       const maxLen = 10
       return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
     })
+
+  // 初始适应视图
+  fitToView()
 }
 
-// 滚动到指定节点
-function scrollToNode(nodeId) {
-  const nodeEl = nodeElements[nodeId]
-  if (!nodeEl) return
+// 适应视图(自动缩放以显示全部内容)
+function fitToView() {
+  if (!zoom || !mainG || !containerRef.value) return
 
   const container = containerRef.value
-  const nodeRect = nodeEl.getBoundingClientRect()
-  const containerRect = container.getBoundingClientRect()
+  const width = container.clientWidth
+  const height = container.clientHeight
 
-  const scrollTop = container.scrollTop + nodeRect.top - containerRect.top - containerRect.height / 2
-  const scrollLeft = container.scrollLeft + nodeRect.left - containerRect.left - containerRect.width / 2
+  // 计算缩放比例以适应容器
+  const scaleX = width / treeWidth
+  const scaleY = height / treeHeight
+  const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
 
-  container.scrollTo({
-    top: Math.max(0, scrollTop),
-    left: Math.max(0, scrollLeft),
-    behavior: 'smooth'
-  })
+  // 计算居中偏移
+  const translateX = (width - treeWidth * scale) / 2
+  const translateY = (height - treeHeight * scale) / 2
+
+  const svg = d3.select(svgRef.value)
+  svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
+}
+
+// 定位到指定节点
+function zoomToNode(nodeId) {
+  const nodeInfo = nodeElements[nodeId]
+  if (!nodeInfo || !zoom || !containerRef.value) return
+
+  const container = containerRef.value
+  const width = container.clientWidth
+  const height = container.clientHeight
+
+  // 计算平移使节点居中
+  const scale = 1
+  const translateX = width / 2 - nodeInfo.x * scale
+  const translateY = height / 2 - nodeInfo.y * scale
+
+  const svg = d3.select(svgRef.value)
+  svg.transition().duration(300).call(
+    zoom.transform,
+    d3.zoomIdentity.translate(translateX, translateY).scale(scale)
+  )
 }
 
 // 更新选中/高亮状态
@@ -284,33 +353,19 @@ function clearHighlight() {
 
 // 点击空白取消激活
 function handleSvgClick(event) {
-  if (event.target.tagName === 'svg') {
+  // 检查是否点击了节点或文字,如果不是则取消激活
+  const target = event.target
+  const isNode = target.closest('.tree-node')
+  if (!isNode) {
     clearHighlight()
   }
 }
 
-// 滚动到根节点(定位到容器正中间)
-function scrollToRoot() {
-  if (!currentRoot) return
-  const container = containerRef.value
-  if (!container) return
-
-  // 垂直布局:x 是水平位置,y 是垂直位置
-  const rootX = currentRoot.x + 50  // 水平位置(加上左边距)
-  const rootY = currentRoot.y + 25  // 垂直位置(加上上边距)
-
-  container.scrollTo({
-    left: Math.max(0, rootX - container.clientWidth / 2),
-    top: Math.max(0, rootY - container.clientHeight / 2),
-    behavior: 'instant'
-  })
-}
-
 // 监听选中变化
 watch(() => store.selectedPostNodeId, (nodeId, oldNodeId) => {
   if (nodeId && nodeId !== oldNodeId) {
     updateSelection()
-    scrollToNode(nodeId)
+    zoomToNode(nodeId)
   } else if (!nodeId) {
     updateSelection()
   }
@@ -323,8 +378,9 @@ watch(() => store.highlightedPostNodeIds.size, () => {
 
 // 监听当前帖子变化,重新渲染树
 watch(() => store.currentPostGraph, () => {
-  renderTree()
-  setTimeout(scrollToRoot, 100)
+  nextTick(() => {
+    renderTree()
+  })
 }, { immediate: false })
 
 // 监听 selectedPostIndex 变化,同步下拉框
@@ -332,8 +388,38 @@ watch(() => store.selectedPostIndex, (newIdx) => {
   selectedPostIdx.value = newIdx
 })
 
+// 监听布局变化,过渡结束后重新适应视图
+function handleTransitionEnd(e) {
+  if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
+    nextTick(() => {
+      renderTree()
+    })
+  }
+}
+
+let transitionParent = null
+
 onMounted(() => {
-  renderTree()
-  setTimeout(scrollToRoot, 100)
+  nextTick(() => {
+    renderTree()
+  })
+
+  // 监听父容器的过渡结束事件
+  if (containerRef.value) {
+    let parent = containerRef.value.parentElement
+    while (parent && !parent.classList.contains('transition-all')) {
+      parent = parent.parentElement
+    }
+    if (parent) {
+      transitionParent = parent
+      parent.addEventListener('transitionend', handleTransitionEnd)
+    }
+  }
+})
+
+onUnmounted(() => {
+  if (transitionParent) {
+    transitionParent.removeEventListener('transitionend', handleTransitionEnd)
+  }
 })
 </script>