Просмотр исходного кода

feat: hover时左右模块联动缩放,人设树和详情卡片联动

- 添加hoverSource追踪hover来源,实现左右模块互相联动缩放
- hover时另一模块自动缩放到路径完整显示
- 人设树hover联动:居中显示被hover的人设节点
- 详情卡片hover联动:显示当前hover节点信息
- 降低置灰透明度提升对比度(0.06/0.03)

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 17 часов назад
Родитель
Сommit
fa2fd14550

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

@@ -146,6 +146,8 @@ let graphNodeSelection = null
 let graphLinkSelection = null
 let graphLinkLabelSelection = null
 let graphLinksData = []
+let graphZoom = null
+let graphG = null
 
 // 游走配置操作(直接操作 store)
 function selectAllEdgeTypes(stepIndex) {
@@ -233,12 +235,14 @@ function renderGraph() {
   }
 
   const g = svg.append('g')
+  graphG = g
 
   // 缩放
   const zoom = d3.zoom()
     .scaleExtent([0.3, 3])
     .on('zoom', (e) => g.attr('transform', e.transform))
   svg.call(zoom)
+  graphZoom = zoom
 
   // 找到中心节点并固定在容器中心
   const centerNodeData = nodes.find(n => n.isCenter)
@@ -324,8 +328,8 @@ function renderGraph() {
     })
     .on('mouseenter', (e, d) => {
       if (d.isCenter) return  // 中心节点不处理
-      // 路径计算由 store 统一处理
-      store.computeHoverPath(centerNodeId, d.id)
+      // 路径计算由 store 统一处理,标记来源为 graph
+      store.computeHoverPath(centerNodeId, d.id, 'graph')
     })
     .on('mouseleave', () => {
       store.clearHover()
@@ -406,12 +410,57 @@ watch(() => store.hoverPathNodes.size, () => {
   if (store.hoverPathNodes.size > 0) {
     // 应用 hover 高亮
     applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes)
+
+    // 如果是从 PostTreeView 触发的,缩放到显示完整路径
+    if (store.hoverSource === 'post-tree') {
+      zoomToPathNodes(store.hoverPathNodes)
+    }
   } else {
     // 清除 hover,恢复原有高亮
     clearHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection)
   }
 })
 
+// 缩放到显示路径上的所有节点
+function zoomToPathNodes(pathNodes) {
+  if (!graphNodeSelection || !graphZoom || !containerRef.value || !svgRef.value) return
+
+  // 收集路径节点的位置
+  const positions = []
+  graphNodeSelection.each(function(d) {
+    if (pathNodes.has(d.id)) {
+      positions.push({ x: d.x, y: d.y })
+    }
+  })
+
+  if (positions.length === 0) return
+
+  // 计算边界框
+  const minX = Math.min(...positions.map(p => p.x))
+  const maxX = Math.max(...positions.map(p => p.x))
+  const minY = Math.min(...positions.map(p => p.y))
+  const maxY = Math.max(...positions.map(p => p.y))
+
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+  const padding = 60
+
+  // 计算需要的缩放和平移
+  const boxWidth = maxX - minX + padding * 2
+  const boxHeight = maxY - minY + padding * 2
+  const scale = Math.min(width / boxWidth, height / boxHeight, 1.5)
+  const centerX = (minX + maxX) / 2
+  const centerY = (minY + maxY) / 2
+  const translateX = width / 2 - centerX * scale
+  const translateY = height / 2 - centerY * scale
+
+  const svg = d3.select(svgRef.value)
+  svg.transition().duration(200).call(
+    graphZoom.transform,
+    d3.zoomIdentity.translate(translateX, translateY).scale(scale)
+  )
+}
+
 // 监听配置变化,重新选中触发游走
 watch([() => store.walkSteps, () => store.stepConfigs], () => {
   if (store.selectedNodeId && isPersonaWalk.value) {

+ 69 - 12
script/visualization/src/components/PostTreeView.vue

@@ -101,7 +101,7 @@
         </div>
         <div class="flex-1 overflow-y-auto p-3 space-y-3">
           <!-- 原始JSON模式 -->
-          <template v-if="showRawData && (store.selectedNode || store.selectedEdge)">
+          <template v-if="showRawData && (displayNode || store.selectedEdge)">
             <div class="relative">
               <button
                 @click="copyJson"
@@ -111,26 +111,28 @@
                 <span v-if="copySuccess">✓</span>
                 <span v-else>📋</span>
               </button>
-              <pre class="text-[10px] bg-base-100 p-2 pr-8 rounded overflow-x-auto whitespace-pre-wrap break-all select-all">{{ JSON.stringify(store.selectedNode || store.selectedEdge, null, 2) }}</pre>
+              <pre class="text-[10px] bg-base-100 p-2 pr-8 rounded overflow-x-auto whitespace-pre-wrap break-all select-all">{{ JSON.stringify(displayNode || store.selectedEdge, null, 2) }}</pre>
             </div>
           </template>
           <!-- 渲染模式 -->
           <template v-else-if="!showRawData">
-            <!-- 节点详情 -->
-            <template v-if="store.selectedNode">
+            <!-- 节点详情(hover 优先于 selected) -->
+            <template v-if="displayNode">
               <div class="flex items-center gap-2">
+                <!-- hover 标识 -->
+                <span v-if="store.hoverNode" class="text-[10px] text-warning/60">[hover]</span>
                 <!-- 节点样式:空心(帖子域)或实心(人设域) -->
                 <span
                   class="w-2.5 h-2.5 shrink-0"
-                  :class="selectedNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
-                  :style="selectedNodeStyle.hollow
-                    ? { backgroundColor: 'transparent', border: '2px solid ' + selectedNodeStyle.color }
-                    : { backgroundColor: selectedNodeStyle.color }"
+                  :class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
+                  :style="displayNodeStyle.hollow
+                    ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
+                    : { backgroundColor: displayNodeStyle.color }"
                 ></span>
-                <span class="text-primary font-medium truncate">{{ store.selectedNode.name }}</span>
+                <span class="text-primary font-medium truncate">{{ displayNode.name }}</span>
               </div>
               <div class="space-y-1.5 text-[11px]">
-                <template v-for="(value, key) in store.selectedNode" :key="key">
+                <template v-for="(value, key) in displayNode" :key="key">
                   <template v-if="key !== 'name' && value !== null && value !== undefined && value !== ''">
                     <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
                       <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
@@ -278,7 +280,16 @@ function getTargetNodeColor(edge) {
   return '#888'
 }
 
-// 选中节点的样式
+// 显示的节点(hover 优先于 selected)
+const displayNode = computed(() => store.hoverNode || store.selectedNode)
+
+// 显示节点的样式
+const displayNodeStyle = computed(() => {
+  if (!displayNode.value) return { color: '#888', shape: 'circle', hollow: false }
+  return getNodeStyle(displayNode.value)
+})
+
+// 选中节点的样式(兼容旧代码)
 const selectedNodeStyle = computed(() => {
   if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
   return getNodeStyle(store.selectedNode)
@@ -932,7 +943,7 @@ function setupHoverHandlers() {
   allNodes
     .on('mouseenter', (event, d) => {
       const nodeId = d.data?.id || d.id
-      store.computeHoverPath(startNodeId, nodeId)
+      store.computeHoverPath(startNodeId, nodeId, 'post-tree')
     })
     .on('mouseleave', () => {
       store.clearHover()
@@ -1135,12 +1146,58 @@ watch(() => store.hoverPathNodes.size, () => {
   if (store.hoverPathNodes.size > 0) {
     // 应用 hover 高亮
     applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes)
+
+    // 如果是从 GraphView 触发的,缩放到显示完整路径
+    if (store.hoverSource === 'graph') {
+      zoomToPathNodes(store.hoverPathNodes)
+    }
   } else {
     // 清除 hover,恢复原有高亮
     updateHighlight()
   }
 })
 
+// 缩放到显示路径上的所有节点
+function zoomToPathNodes(pathNodes) {
+  if (!zoom || !containerRef.value || !svgRef.value) return
+
+  // 收集路径节点的位置
+  const positions = []
+  for (const nodeId of pathNodes) {
+    const nodeInfo = nodeElements[nodeId]
+    if (nodeInfo) {
+      positions.push({ x: nodeInfo.x, y: nodeInfo.y })
+    }
+  }
+
+  if (positions.length === 0) return
+
+  // 计算边界框
+  const minX = Math.min(...positions.map(p => p.x))
+  const maxX = Math.max(...positions.map(p => p.x))
+  const minY = Math.min(...positions.map(p => p.y))
+  const maxY = Math.max(...positions.map(p => p.y))
+
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+  const padding = 60
+
+  // 计算需要的缩放和平移
+  const boxWidth = maxX - minX + padding * 2
+  const boxHeight = maxY - minY + padding * 2
+  const scale = Math.min(width / boxWidth, height / boxHeight, 1.5)
+  const centerX = (minX + maxX) / 2
+  const centerY = (minY + maxY) / 2
+  const translateX = width / 2 - centerX * scale
+  const translateY = height / 2 - centerY * scale
+
+  const svg = d3.select(svgRef.value)
+  svg.transition().duration(200).call(
+    zoom.transform,
+    d3.zoomIdentity.translate(translateX, translateY).scale(scale)
+  )
+}
+
 // 监听帖子游走结果变化,渲染游走层
 watch(() => store.postWalkedNodes.length, () => {
   nextTick(renderWalkedLayer)

+ 24 - 1
script/visualization/src/components/TreeView.vue

@@ -66,7 +66,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 import { dimColors, getNodeStyle, applyNodeShape } from '../config/nodeStyle'
-import { applyHighlight } from '../utils/highlight'
+import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
   hideHeader: {
@@ -333,6 +333,29 @@ watch(() => store.selectedEdgeId, updateSelection)
 
 watch(() => store.highlightedNodeIds.size, updateSelection)
 
+// 监听 hover 状态变化(人设树联动)
+watch(() => store.hoverPathNodes.size, () => {
+  if (!svgRef.value) return
+  const svg = d3.select(svgRef.value)
+
+  const allNodes = svg.selectAll('.tree-node')
+  const allLinks = svg.selectAll('.tree-link')
+
+  if (store.hoverPathNodes.size > 0) {
+    // 应用路径高亮
+    applyHoverHighlight(allNodes, allLinks, null, store.hoverPathNodes)
+
+    // 如果 hover 的节点在人设树中,居中显示它(跟点击效果一样)
+    const hoverNodeId = store.hoverNodeId
+    if (hoverNodeId && nodeElements[hoverNodeId]) {
+      zoomToNode(hoverNodeId)
+    }
+  } else {
+    // 恢复原有高亮
+    updateSelection()
+  }
+})
+
 // 监听布局变化,过渡结束后重新适应视图
 function handleTransitionEnd(e) {
   if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {

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

@@ -655,9 +655,11 @@ export const useGraphStore = defineStore('graph', () => {
   // ==================== Hover 状态(左右联动) ====================
   const hoverNodeId = ref(null)  // 当前 hover 的节点 ID
   const hoverPathNodes = ref(new Set())  // hover 路径上的节点集合
+  const hoverSource = ref(null)  // hover 来源: 'graph' | 'post-tree'
 
   // 计算 hover 路径(基于已高亮的边)
-  function computeHoverPath(startId, endId) {
+  // source: 触发 hover 的模块标识
+  function computeHoverPath(startId, endId, source = null) {
     if (!startId || !endId || startId === endId) {
       clearHover()
       return
@@ -713,6 +715,7 @@ export const useGraphStore = defineStore('graph', () => {
     if (pathNodes.size > 0) {
       hoverNodeId.value = endId
       hoverPathNodes.value = pathNodes
+      hoverSource.value = source
     }
   }
 
@@ -720,6 +723,7 @@ export const useGraphStore = defineStore('graph', () => {
   function clearHover() {
     hoverNodeId.value = null
     hoverPathNodes.value = new Set()
+    hoverSource.value = null
   }
 
   // 计算属性:当前选中节点的数据
@@ -727,6 +731,11 @@ export const useGraphStore = defineStore('graph', () => {
     return selectedNodeId.value ? getNode(selectedNodeId.value) : null
   })
 
+  // 计算属性:当前 hover 节点的数据
+  const hoverNode = computed(() => {
+    return hoverNodeId.value ? getNode(hoverNodeId.value) : null
+  })
+
   // 计算属性:当前选中边的数据
   const selectedEdge = computed(() => {
     return selectedEdgeId.value ? getEdge(selectedEdgeId.value) : null
@@ -784,6 +793,7 @@ export const useGraphStore = defineStore('graph', () => {
     focusEdgeEndpoints,
     selectedNode,
     selectedEdge,
+    hoverNode,
     getNode,
     getEdge,
     selectNode,
@@ -792,6 +802,7 @@ export const useGraphStore = defineStore('graph', () => {
     // Hover 联动
     hoverNodeId,
     hoverPathNodes,
+    hoverSource,
     computeHoverPath,
     clearHover,
     // 布局

+ 5 - 4
script/visualization/src/style.css

@@ -99,7 +99,7 @@
   .match-node.dimmed,
   .graph-node.dimmed,
   .walked-node.dimmed {
-    opacity: 0.15;
+    opacity: 0.06;
     pointer-events: none;
   }
 
@@ -108,13 +108,14 @@
   .match-link.dimmed,
   .graph-link.dimmed,
   .walked-link.dimmed {
-    stroke-opacity: 0.08 !important;
+    stroke-opacity: 0.03 !important;
   }
 
   /* 分数标签置灰 */
   .match-score.dimmed,
-  .walked-score.dimmed {
-    opacity: 0.15;
+  .walked-score.dimmed,
+  .graph-link-label.dimmed {
+    opacity: 0.06;
   }
 
   /* ========== 统一的高亮样式 ========== */