Преглед изворни кода

feat: 优化布局变化时的渲染和过渡效果

- GraphView: 中心节点通过fx/fy固定在容器中心
- GraphView: 布局变化时内容淡入淡出,使用transitionend事件
- TreeView: 滚动条默认居中,布局变化后恢复滚动位置
- PostTreeView: 滚动条默认居中

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui пре 23 часа
родитељ
комит
fdc86fab6b

+ 27 - 16
script/visualization/src/App.vue

@@ -9,12 +9,12 @@
         <a
           class="tab tab-sm"
           :class="{ 'tab-active': activeTab === 'persona' }"
-          @click="activeTab = 'persona'"
+          @click="switchTab('persona')"
         >人设图谱</a>
         <a
           class="tab tab-sm"
           :class="{ 'tab-active': activeTab === 'match' }"
-          @click="activeTab = 'match'"
+          @click="switchTab('match')"
         >帖子匹配</a>
       </div>
 
@@ -53,24 +53,24 @@
     </header>
 
     <!-- 主内容区 - 人设图谱 Tab -->
-    <main v-show="activeTab === 'persona'" class="flex flex-1 overflow-hidden">
+    <main v-if="activeTab === 'persona'" class="flex flex-1 overflow-hidden">
       <TreeView class="w-[420px] shrink-0 bg-base-200 border-r border-base-300" />
       <GraphView class="flex-1" />
     </main>
 
     <!-- 主内容区 - 帖子匹配 Tab -->
-    <main v-show="activeTab === 'match'" class="flex flex-1 overflow-hidden">
+    <main v-else-if="activeTab === 'match'" class="flex flex-1 overflow-hidden">
       <!-- 左侧:人设树 + 相关图(上下布局) -->
       <div
         class="shrink-0 bg-base-200 border-r border-base-300 flex flex-col transition-all duration-200"
         :class="getLeftPanelClass()"
       >
-        <!-- 人设树(上部) -->
+        <!-- 人设树(上部,占50%) -->
         <div
-          class="flex flex-col transition-all duration-200 border-b border-base-300"
-          :class="getPersonaTreeClass()"
+          class="flex flex-col transition-all duration-200 h-1/2 shrink-0"
+          :class="{ 'h-full': store.expandedPanel === 'persona-tree', 'h-10': store.expandedPanel === 'graph' }"
         >
-          <div class="flex items-center justify-between px-4 py-1 bg-base-300 text-xs text-base-content/60 shrink-0">
+          <div class="flex items-center justify-between px-4 py-1 bg-base-300 text-xs text-base-content/60 shrink-0 border-b border-base-300">
             <span>人设树</span>
             <div class="flex gap-1">
               <button
@@ -87,15 +87,15 @@
               >⊡</button>
             </div>
           </div>
-          <TreeView class="flex-1 min-h-0" :hide-header="true" />
+          <TreeView class="flex-1 min-h-0 overflow-auto" :hide-header="true" />
         </div>
-        <!-- 相关图(下部,未激活时收起) -->
+        <!-- 相关图(下部,占50%) -->
         <div
           v-show="store.expandedPanel !== 'persona-tree'"
-          class="flex flex-col transition-all duration-200"
-          :class="getGraphClass()"
+          class="flex-1 border-t border-base-300"
+          :class="{ 'h-full': store.expandedPanel === 'graph' }"
         >
-          <GraphView :collapsed="!store.selectedNodeId" :show-expand="true" />
+          <GraphView class="h-full" :show-expand="true" />
         </div>
       </div>
       <!-- 右侧:帖子树 -->
@@ -125,6 +125,15 @@ const store = useGraphStore()
 // 当前激活的 Tab
 const activeTab = ref('persona')
 
+// 切换 Tab 时清除选中状态,避免干扰
+function switchTab(tab) {
+  if (activeTab.value !== tab) {
+    store.clearSelection()
+    store.resetLayout()
+    activeTab.value = tab
+  }
+}
+
 // 边类型颜色调色板
 const edgeColorPalette = ['#9b59b6', '#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#1abc9c']
 
@@ -153,15 +162,17 @@ function getLeftPanelClass() {
 function getPersonaTreeClass() {
   const panel = store.expandedPanel
   if (panel === 'persona-tree') return 'flex-1'
-  if (panel === 'graph') return 'h-8 overflow-hidden'
+  if (panel === 'graph') return 'h-10 shrink-0'
+  // 默认:根据相关图状态调整
+  if (store.selectedNodeId) return 'flex-1'
   return 'flex-1'
 }
 
 function getGraphClass() {
   const panel = store.expandedPanel
   if (panel === 'graph') return 'flex-1'
-  if (store.selectedNodeId) return 'h-[45%]'
-  return 'h-8'
+  if (store.selectedNodeId) return 'h-[280px] shrink-0'
+  return 'h-10 shrink-0'
 }
 
 function getPostTreeClass() {

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

@@ -3,13 +3,12 @@
     <!-- 头部 -->
     <div class="flex items-center gap-3 px-4 py-2 bg-base-300 text-xs text-base-content/60 shrink-0">
       <span>相关图</span>
-      <span v-if="!collapsed" class="text-primary font-medium">{{ currentNodeName }}</span>
-      <span v-else class="text-base-content/40">点击人设树节点查看</span>
+      <span v-if="store.selectedNodeId" class="text-primary font-medium">{{ currentNodeName }}</span>
       <div class="flex-1"></div>
-      <button v-if="!collapsed" @click="showConfig = !showConfig" class="btn btn-ghost btn-xs">
+      <button v-if="store.selectedNodeId" @click="showConfig = !showConfig" class="btn btn-ghost btn-xs">
         {{ showConfig ? '隐藏配置' : '游走配置' }}
       </button>
-      <template v-if="showExpand && !collapsed">
+      <template v-if="showExpand && store.selectedNodeId">
         <button
           v-if="store.expandedPanel !== 'graph'"
           @click="store.expandPanel('graph')"
@@ -26,7 +25,7 @@
     </div>
 
     <!-- 游走配置面板 -->
-    <div v-show="showConfig && !collapsed" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
+    <div v-show="showConfig" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
       <!-- 步数设置 -->
       <div class="flex items-center gap-2">
         <span class="text-base-content/60 w-16">游走步数:</span>
@@ -60,14 +59,18 @@
     </div>
 
     <!-- SVG 容器 -->
-    <div v-show="!collapsed" ref="containerRef" class="flex-1 relative overflow-hidden">
-      <svg ref="svgRef" class="w-full h-full"></svg>
+    <div ref="containerRef" class="flex-1 relative overflow-hidden">
+      <svg ref="svgRef" class="w-full h-full transition-opacity duration-200"></svg>
+      <!-- 未选中节点时的提示 -->
+      <div v-if="!store.selectedNodeId" class="absolute inset-0 flex items-center justify-center text-base-content/30 text-sm">
+        点击人设树节点查看相关图
+      </div>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, reactive, computed, watch, onMounted } from 'vue'
+import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 
@@ -235,6 +238,12 @@ function getNodeColor(node) {
 
 // 渲染相关图
 function renderGraph() {
+  // 停止旧的 simulation
+  if (simulation) {
+    simulation.stop()
+    simulation = null
+  }
+
   const svg = d3.select(svgRef.value)
   svg.selectAll('*').remove()
 
@@ -292,12 +301,18 @@ function renderGraph() {
     .on('zoom', (e) => g.attr('transform', e.transform))
   svg.call(zoom)
 
-  // 力导向模拟
+  // 找到中心节点并固定在容器中心
+  const centerNodeData = nodes.find(n => n.isCenter)
+  if (centerNodeData) {
+    centerNodeData.fx = width / 2
+    centerNodeData.fy = height / 2
+  }
+
+  // 力导向模拟(中心节点已固定,其他节点围绕它布局)
   simulation = d3.forceSimulation(nodes)
-    .force('link', d3.forceLink(links).id(d => d.id).distance(100))
-    .force('charge', d3.forceManyBody().strength(-200))
-    .force('center', d3.forceCenter(width / 2, height / 2))
-    .force('collision', d3.forceCollide().radius(35))
+    .force('link', d3.forceLink(links).id(d => d.id).distance(80))
+    .force('charge', d3.forceManyBody().strength(-150))
+    .force('collision', d3.forceCollide().radius(30))
 
   // 边
   const link = g.append('g')
@@ -398,7 +413,10 @@ function handleSvgClick(event) {
 
 watch(() => store.selectedNodeId, (nodeId) => {
   if (nodeId) {
-    executeWalk()  // 点击节点自动执行游走
+    // 使用 nextTick 确保容器尺寸正确
+    nextTick(() => {
+      executeWalk()
+    })
   } else {
     renderGraph()
   }
@@ -407,11 +425,60 @@ watch(() => store.selectedNodeId, (nodeId) => {
 // 监听配置变化,及时重新游走
 watch([walkSteps, stepConfigs], () => {
   if (store.selectedNodeId) {
-    executeWalk()
+    nextTick(() => {
+      executeWalk()
+    })
   }
 }, { deep: true })
 
+
+// 监听 CSS 过渡结束后重新渲染
+function handleTransitionEnd(e) {
+  // 只处理尺寸相关的过渡(width, height, flex 等)
+  if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
+    if (store.selectedNodeId && svgRef.value) {
+      executeWalk()
+      // 渲染后淡入
+      svgRef.value.style.opacity = '1'
+    }
+  }
+}
+
+// 布局变化时先淡出
+watch(() => store.expandedPanel, () => {
+  if (svgRef.value) {
+    svgRef.value.style.opacity = '0'
+  }
+})
+
 onMounted(() => {
-  renderGraph()
+  nextTick(() => {
+    renderGraph()
+
+    // 监听父容器的过渡结束事件
+    if (containerRef.value) {
+      // 向上找到有 transition 的父容器
+      let parent = containerRef.value.parentElement
+      while (parent && !parent.classList.contains('transition-all')) {
+        parent = parent.parentElement
+      }
+      if (parent) {
+        parent.addEventListener('transitionend', handleTransitionEnd)
+      }
+    }
+  })
+})
+
+// 组件卸载时清理
+onUnmounted(() => {
+  if (containerRef.value) {
+    let parent = containerRef.value.parentElement
+    while (parent && !parent.classList.contains('transition-all')) {
+      parent = parent.parentElement
+    }
+    if (parent) {
+      parent.removeEventListener('transitionend', handleTransitionEnd)
+    }
+  }
 })
 </script>

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

@@ -289,15 +289,21 @@ function handleSvgClick(event) {
   }
 }
 
-// 滚动到根节点
+// 滚动到根节点(定位到容器正中间)
 function scrollToRoot() {
   if (!currentRoot) return
   const container = containerRef.value
   if (!container) return
 
-  const rootY = currentRoot.x + 25
-  const targetScroll = rootY - container.clientHeight / 2
-  container.scrollTop = Math.max(0, targetScroll)
+  // 垂直布局: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'
+  })
 }
 
 // 监听选中变化

+ 83 - 8
script/visualization/src/components/TreeView.vue

@@ -62,7 +62,7 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 
@@ -346,15 +346,23 @@ function handleSvgClick(event) {
   }
 }
 
-// 滚动到根节点
-function scrollToRoot() {
-  if (!currentRoot) return
+// 滚动条默认滚到中间
+function scrollToCenter() {
   const container = containerRef.value
   if (!container) return
 
-  const rootY = currentRoot.x + 25
-  const targetScroll = rootY - container.clientHeight / 2
-  container.scrollTop = Math.max(0, targetScroll)
+  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'
+  })
 }
 
 // 监听选中变化(从外部触发)
@@ -373,8 +381,75 @@ 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()
+    }
+  }
+}
+
+let transitionParent = null
+
 onMounted(() => {
   renderTree()
-  setTimeout(scrollToRoot, 100)
+  setTimeout(scrollToCenter, 100)
+
+  // 监听父容器的过渡结束事件
+  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>