瀏覽代碼

feat: 人设树改为缩放模式

- 从滚动模式改为d3.zoom缩放模式
- 支持鼠标滚轮缩放和拖拽平移
- 初始自动适应视图显示全部内容
- 搜索/点击节点时平滑定位到节点

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 1 天之前
父節點
當前提交
cba1a49f00
共有 1 個文件被更改,包括 88 次插入93 次删除
  1. 88 93
      script/visualization/src/components/TreeView.vue

+ 88 - 93
script/visualization/src/components/TreeView.vue

@@ -55,14 +55,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, onUnmounted, watch } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 import { dimColors, getNodeStyle } from '../config/nodeStyle'
@@ -79,6 +79,12 @@ const store = useGraphStore()
 const containerRef = ref(null)
 const svgRef = ref(null)
 
+// zoom 实例和主 g 元素
+let zoom = null
+let mainG = null
+let treeWidth = 0
+let treeHeight = 0
+
 // 搜索相关
 const searchQuery = ref('')
 const showSuggestions = ref(false)
@@ -129,7 +135,7 @@ function selectNode(node) {
   searchQuery.value = ''
   showSuggestions.value = false
   updateSelection()
-  scrollToNode(node.id)
+  zoomToNode(node.id)
 }
 
 // 根据节点数据获取颜色(用于搜索列表)
@@ -151,7 +157,7 @@ function handleNodeClick(event, d) {
   updateSelection()
 }
 
-// 渲染树(完整显示,不过滤
+// 渲染树(缩放模式
 function renderTree() {
   const svg = d3.select(svgRef.value)
   svg.selectAll('*').remove()
@@ -160,6 +166,14 @@ function renderTree() {
   const treeData = store.treeData
   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
@@ -170,11 +184,21 @@ function renderTree() {
   const leafCount = allNodes.filter(d => !d.children).length
 
   // 高度:基于叶子节点数量
-  const treeHeight = Math.max(800, leafCount * 20 + 100)
+  treeHeight = Math.max(800, leafCount * 20 + 100)
   // 宽度:根据深度
-  const treeWidth = Math.max(600, (maxDepth + 1) * 150 + 50)
+  treeWidth = Math.max(600, (maxDepth + 1) * 150 + 50)
 
-  svg.attr('width', treeWidth).attr('height', treeHeight + 50)
+  // 创建缩放行为
+  zoom = d3.zoom()
+    .scaleExtent([0.1, 3])
+    .on('zoom', (e) => {
+      mainG.attr('transform', e.transform)
+    })
+
+  svg.call(zoom)
+
+  // 创建主组
+  mainG = svg.append('g')
 
   // 创建树布局
   const treeLayout = d3.tree()
@@ -183,12 +207,12 @@ function renderTree() {
 
   treeLayout(root)
 
-  // 创建主组
-  const g = svg.append('g')
+  // 内容组(带偏移)
+  const contentG = mainG.append('g')
     .attr('transform', 'translate(25, 25)')
 
   // 绘制边
-  g.append('g')
+  contentG.append('g')
     .attr('class', 'tree-edges')
     .selectAll('.tree-link')
     .data(root.links())
@@ -204,7 +228,7 @@ function renderTree() {
     })
 
   // 绘制节点
-  const nodes = g.append('g')
+  const nodes = contentG.append('g')
     .attr('class', 'tree-nodes')
     .selectAll('.tree-node')
     .data(root.descendants())
@@ -244,7 +268,8 @@ function renderTree() {
         .attr('stroke-width', 1)
     }
 
-    nodeElements[d.data.id] = this
+    // 记录节点位置(用于 zoomToNode)
+    nodeElements[d.data.id] = { element: this, x: d.y + 25, y: d.x + 25 }
   })
 
   // 节点标签(使用统一配置)
@@ -260,25 +285,51 @@ function renderTree() {
       const maxLen = d.children ? 6 : 8
       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)
+  )
 }
 
 // 更新选中/高亮状态
@@ -310,38 +361,19 @@ function clearHighlight() {
 
 // 点击空白取消激活
 function handleSvgClick(event) {
-  // 只有点击 SVG 背景才清除
-  if (event.target.tagName === 'svg') {
+  const target = event.target
+  const isNode = target.closest('.tree-node')
+  if (!isNode) {
     clearHighlight()
   }
 }
 
-// 滚动条默认滚到中间
-function scrollToCenter() {
-  const container = containerRef.value
-  if (!container) return
-
-  const svg = svgRef.value
-  if (!svg) return
-
-  // 滚动到内容的中间位置
-  const scrollLeft = (svg.clientWidth - container.clientWidth) / 2
-  const scrollTop = (svg.clientHeight - container.clientHeight) / 2
-
-  container.scrollTo({
-    left: Math.max(0, scrollLeft),
-    top: Math.max(0, scrollTop),
-    behavior: 'instant'
-  })
-}
-
 // 监听选中变化(从外部触发)
 watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
   if (nodeId && nodeId !== oldNodeId) {
     updateSelection()
-    scrollToNode(nodeId)
+    zoomToNode(nodeId)
   } else if (!nodeId) {
-    // 清除选中时也要更新
     updateSelection()
   }
 })
@@ -351,58 +383,21 @@ watch(() => store.highlightedNodeIds.size, () => {
   updateSelection()
 })
 
-// 记录滚动比例,用于布局变化后恢复
-let scrollRatioX = 0.5
-let scrollRatioY = 0.5
-
-// 保存当前滚动比例
-function saveScrollRatio() {
-  const container = containerRef.value
-  if (!container) return
-
-  const maxScrollLeft = container.scrollWidth - container.clientWidth
-  const maxScrollTop = container.scrollHeight - container.clientHeight
-
-  scrollRatioX = maxScrollLeft > 0 ? container.scrollLeft / maxScrollLeft : 0.5
-  scrollRatioY = maxScrollTop > 0 ? container.scrollTop / maxScrollTop : 0.5
-}
-
-// 恢复滚动比例
-function restoreScrollRatio() {
-  const container = containerRef.value
-  if (!container) return
-
-  const maxScrollLeft = container.scrollWidth - container.clientWidth
-  const maxScrollTop = container.scrollHeight - container.clientHeight
-
-  container.scrollTo({
-    left: maxScrollLeft * scrollRatioX,
-    top: maxScrollTop * scrollRatioY,
-    behavior: 'instant'
-  })
-}
-
-// 布局变化时保存滚动位置
-watch(() => store.expandedPanel, () => {
-  saveScrollRatio()
-})
-
-// 监听过渡结束,恢复滚动位置
+// 监听布局变化,过渡结束后重新适应视图
 function handleTransitionEnd(e) {
   if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
-    if (store.selectedNodeId) {
-      scrollToNode(store.selectedNodeId)
-    } else {
-      restoreScrollRatio()
-    }
+    nextTick(() => {
+      renderTree()
+    })
   }
 }
 
 let transitionParent = null
 
 onMounted(() => {
-  renderTree()
-  setTimeout(scrollToCenter, 100)
+  nextTick(() => {
+    renderTree()
+  })
 
   // 监听父容器的过渡结束事件
   if (containerRef.value) {