Ver Fonte

refactor: 统一高亮逻辑,基于游走的节点集合和边集合

- 添加 walkedEdgeSet 计算属性用于边的高亮判断
- 创建 utils/highlight.js 统一高亮工具函数
- 所有边使用 data binding,统一数据结构
- 统一节点位置存储,修复匹配节点视图居中

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui há 1 dia atrás
pai
commit
1ef66dbc31

+ 35 - 144
script/visualization/src/components/GraphView.vue

@@ -29,13 +29,13 @@
       <!-- 步数设置 -->
       <div class="flex items-center gap-2">
         <span class="text-base-content/60 w-16">游走步数:</span>
-        <input type="range" :min="1" :max="5" v-model.number="walkSteps" class="range range-xs range-primary flex-1" />
-        <span class="w-6 text-center">{{ walkSteps }}</span>
+        <input type="range" :min="1" :max="5" v-model.number="store.walkSteps" class="range range-xs range-primary flex-1" />
+        <span class="w-6 text-center">{{ store.walkSteps }}</span>
       </div>
 
       <!-- 分步设置 -->
       <div class="space-y-2">
-        <div v-for="step in walkSteps" :key="step" class="pl-4 space-y-1 border-l-2 border-secondary/30">
+        <div v-for="step in store.walkSteps" :key="step" class="pl-4 space-y-1 border-l-2 border-secondary/30">
           <div class="flex items-center gap-2">
             <span class="font-medium text-secondary">第 {{ step }} 步</span>
             <button @click="selectAllEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">全选</button>
@@ -44,15 +44,15 @@
           </div>
           <div class="flex items-center gap-2 flex-wrap">
             <span class="text-base-content/60 w-14">边类型:</span>
-            <label v-for="et in allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
-              <input type="checkbox" v-model="stepConfigs[step-1].edgeTypes" :value="et" class="checkbox checkbox-xs" />
+            <label v-for="et in store.allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
+              <input type="checkbox" v-model="store.stepConfigs[step-1].edgeTypes" :value="et" class="checkbox checkbox-xs" />
               <span :style="{ color: edgeTypeColors[et] }">{{ et }}</span>
             </label>
           </div>
           <div class="flex items-center gap-2">
             <span class="text-base-content/60 w-14">最小分:</span>
-            <input type="range" :min="0" :max="1" :step="0.1" v-model.number="stepConfigs[step-1].minScore" class="range range-xs flex-1" />
-            <span class="w-8 text-center">{{ stepConfigs[step-1].minScore.toFixed(1) }}</span>
+            <input type="range" :min="0" :max="1" :step="0.1" v-model.number="store.stepConfigs[step-1].minScore" class="range range-xs flex-1" />
+            <span class="w-8 text-center">{{ store.stepConfigs[step-1].minScore.toFixed(1) }}</span>
           </div>
         </div>
       </div>
@@ -70,142 +70,36 @@
 </template>
 
 <script setup>
-import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 import { getNodeStyle } from '../config/nodeStyle'
 import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
 
-// Props
 const props = defineProps({
-  collapsed: {
-    type: Boolean,
-    default: false
-  },
-  showExpand: {
-    type: Boolean,
-    default: false
-  }
+  collapsed: { type: Boolean, default: false },
+  showExpand: { type: Boolean, default: false }
 })
 
 const store = useGraphStore()
-
 const containerRef = ref(null)
 const svgRef = ref(null)
+const showConfig = ref(false)
 
 let simulation = null
 
-// 从数据中动态获取所有边类型(用于游走配置)
-const allEdgeTypes = computed(() => {
-  const types = new Set()
-  const edges = store.graphData.edges || {}
-  for (const edge of Object.values(edges)) {
-    if (edge.type) types.add(edge.type)
-  }
-  return Array.from(types)
-})
-
-// 游走配置
-const showConfig = ref(false)
-const walkSteps = ref(2)
-
-// 分步设置(最多5步)
-// 默认:第1步全选,第2步及以后只选"属于"
-const stepConfigs = reactive([
-  { edgeTypes: [], minScore: 0 },
-  { edgeTypes: ['属于'], minScore: 0 },
-  { edgeTypes: ['属于'], minScore: 0 },
-  { edgeTypes: ['属于'], minScore: 0 },
-  { edgeTypes: ['属于'], minScore: 0 }
-])
-
-// 监听边类型变化,初始化第1步为全选
-watch(allEdgeTypes, (types) => {
-  if (stepConfigs[0].edgeTypes.length === 0) {
-    stepConfigs[0].edgeTypes = [...types]
-  }
-}, { immediate: true })
-
-// 全选边类型
+// 游走配置操作(直接操作 store)
 function selectAllEdgeTypes(stepIndex) {
-  stepConfigs[stepIndex].edgeTypes = [...allEdgeTypes.value]
+  store.stepConfigs[stepIndex].edgeTypes = [...store.allEdgeTypes]
 }
 
-// 清空边类型
 function clearEdgeTypes(stepIndex) {
-  stepConfigs[stepIndex].edgeTypes = []
+  store.stepConfigs[stepIndex].edgeTypes = []
 }
 
-// 重置为默认边类型(第1步全选,第2步及以后只选"属于")
 function resetEdgeTypes(stepIndex) {
-  if (stepIndex === 0) {
-    stepConfigs[stepIndex].edgeTypes = [...allEdgeTypes.value]
-  } else {
-    stepConfigs[stepIndex].edgeTypes = ['属于']
-  }
-  stepConfigs[stepIndex].minScore = 0
-}
-
-// 游走时记录的边
-let walkedEdges = []
-
-// 执行游走
-function executeWalk() {
-  if (!store.selectedNodeId) return
-
-  const visited = new Set([store.selectedNodeId])
-  let currentFrontier = new Set([store.selectedNodeId])
-  walkedEdges = []  // 清空之前的边记录
-
-  for (let step = 0; step < walkSteps.value; step++) {
-    const config = stepConfigs[step]
-
-    const nextFrontier = new Set()
-
-    for (const nodeId of currentFrontier) {
-      const neighbors = getFilteredNeighbors(nodeId, config)
-      for (const n of neighbors) {
-        if (!visited.has(n.nodeId)) {
-          visited.add(n.nodeId)
-          nextFrontier.add(n.nodeId)
-          // 记录走过的边(固定出边方向)
-          walkedEdges.push({
-            source: nodeId,
-            target: n.nodeId,
-            type: n.edgeType,
-            score: n.score
-          })
-        }
-      }
-    }
-
-    currentFrontier = nextFrontier
-    if (currentFrontier.size === 0) break
-  }
-
-  // 更新高亮节点
-  store.highlightedNodeIds = visited
-  renderGraph()
-}
-
-// 根据配置获取过滤后的邻居(固定沿出边游走)
-function getFilteredNeighbors(nodeId, config) {
-  const neighbors = []
-  const index = store.graphData.index
-  const outEdges = index.outEdges?.[nodeId] || {}
-
-  for (const [edgeType, targets] of Object.entries(outEdges)) {
-    if (!config.edgeTypes.includes(edgeType)) continue
-
-    for (const t of targets) {
-      const score = t.score || 0
-      if (score >= config.minScore) {
-        neighbors.push({ nodeId: t.target, edgeType, score })
-      }
-    }
-  }
-
-  return neighbors
+  store.stepConfigs[stepIndex].edgeTypes = stepIndex === 0 ? [...store.allEdgeTypes] : ['属于']
+  store.stepConfigs[stepIndex].minScore = 0
 }
 
 const currentNodeName = computed(() => {
@@ -227,9 +121,15 @@ function renderGraph() {
 
   if (!store.selectedNodeId) return
 
+  // 只有配置的节点类型才显示相关图
+  if (!store.shouldWalk(store.selectedNodeId)) return
+
   const container = containerRef.value
+  if (!container) return
+
   const width = container.clientWidth
   const height = container.clientHeight
+  if (width <= 0 || height <= 0) return
 
   svg.attr('viewBox', `0 0 ${width} ${height}`)
 
@@ -268,8 +168,12 @@ function renderGraph() {
     nodeSet.add(centerNodeId)
   }
 
-  // 使用游走时记录的边(只显示实际走过的路径)
-  links.push(...walkedEdges)
+  // 使用游走时记录的边(只显示两端节点都存在的边)
+  for (const edge of store.walkedEdges) {
+    if (nodeSet.has(edge.source) && nodeSet.has(edge.target)) {
+      links.push({ ...edge })
+    }
+  }
 
   const g = svg.append('g')
 
@@ -373,39 +277,26 @@ function renderGraph() {
 function handleSvgClick(event) {
   if (event.target.tagName === 'svg') {
     store.clearSelection()
-    walkedEdges = []
-    renderGraph()
   }
 }
 
-watch(() => store.selectedNodeId, (nodeId) => {
-  if (nodeId) {
-    // 使用 nextTick 确保容器尺寸正确
-    nextTick(() => {
-      executeWalk()
-    })
-  } else {
-    renderGraph()
-  }
+// 监听高亮变化(walkedEdges 变化时重新渲染)
+watch(() => store.walkedEdges.length, () => {
+  nextTick(renderGraph)
 })
 
-// 监听配置变化,及时重新游走
-watch([walkSteps, stepConfigs], () => {
+// 监听配置变化,重新选中触发游走
+watch([() => store.walkSteps, () => store.stepConfigs], () => {
   if (store.selectedNodeId) {
-    nextTick(() => {
-      executeWalk()
-    })
+    store.selectNode(store.selectedNodeId)
   }
 }, { 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()
-      // 渲染后淡入
+      renderGraph()
       svgRef.value.style.opacity = '1'
     }
   }

+ 79 - 102
script/visualization/src/components/PostTreeView.vue

@@ -4,8 +4,8 @@
     <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
       <span>帖子树</span>
       <div class="flex items-center gap-2">
-        <span v-if="store.highlightedPostNodeIds.size > 0" class="text-primary">
-          已高亮 {{ store.highlightedPostNodeIds.size }} 个节点
+        <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
+          已高亮 {{ store.highlightedNodeIds.size }} 个节点
         </span>
         <template v-if="showExpand">
           <button
@@ -50,11 +50,12 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
-import { dimColors, getNodeStyle } from '../config/nodeStyle'
-import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
+import { getNodeStyle } from '../config/nodeStyle'
+import { getEdgeStyle } from '../config/edgeStyle'
+import { applyHighlight } from '../utils/highlight'
 
 const props = defineProps({
   showExpand: {
@@ -90,16 +91,14 @@ function formatPostOption(post) {
   return date ? `${date} ${shortTitle}` : shortTitle
 }
 
-// 节点元素映射
+// 节点元素映射(统一存储所有节点位置)
 let nodeElements = {}
 let currentRoot = null
 
 // 处理节点点击
 function handleNodeClick(event, d) {
   event.stopPropagation()
-  const nodeId = d.data.id
-  store.selectPostNode(nodeId)
-  updateSelection()
+  store.selectNode(d)
 }
 
 // 渲染树
@@ -197,12 +196,7 @@ function renderTree() {
     .selectAll('.tree-node')
     .data(root.descendants())
     .join('g')
-    .attr('class', d => {
-      let cls = 'tree-node'
-      if (store.selectedPostNodeId === d.data.id) cls += ' selected'
-      if (store.highlightedPostNodeIds.has(d.data.id)) cls += ' highlighted'
-      return cls
-    })
+    .attr('class', 'tree-node')
     .attr('transform', d => `translate(${d.x},${d.y})`)
     .style('cursor', 'pointer')
     .on('click', handleNodeClick)
@@ -313,57 +307,70 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     persona.y = matchLayerY
   })
 
-  // 绘制匹配连线
-  const matchLinksG = contentG.append('g').attr('class', 'match-links')
-
+  // 收集匹配边数据(统一数据结构:source, target)
+  const matchLinksData = []
   for (const persona of matchedPersonas) {
     for (const srcEdge of persona.sourceEdges) {
       const sourceNode = nodeElements[srcEdge.sourceId]
       if (!sourceNode) continue
-
-      // 获取源节点位置(需要减去 contentG 的偏移)
-      const srcX = sourceNode.x - 50
-      const srcY = sourceNode.y - 25
-
-      const midY = (srcY + persona.y) / 2
-      const midX = (srcX + persona.x) / 2
-      const style = getEdgeStyle({ type: '匹配', score: srcEdge.score })
-
-      matchLinksG.append('path')
-        .attr('class', 'match-link')
-        .attr('fill', 'none')
-        .attr('stroke', style.color)
-        .attr('stroke-opacity', style.opacity)
-        .attr('stroke-width', style.strokeWidth)
-        .attr('stroke-dasharray', style.strokeDasharray)
-        .attr('d', `M${srcX},${srcY} C${srcX},${midY} ${persona.x},${midY} ${persona.x},${persona.y}`)
-
-      // 显示分数(带背景)
-      if (style.scoreText) {
-        const scoreG = matchLinksG.append('g')
-          .attr('transform', `translate(${midX}, ${midY})`)
-
-        // 背景矩形
-        scoreG.append('rect')
-          .attr('x', -14)
-          .attr('y', -6)
-          .attr('width', 28)
-          .attr('height', 12)
-          .attr('rx', 2)
-          .attr('fill', '#1d232a')
-          .attr('opacity', 0.9)
-
-        // 分数文字
-        scoreG.append('text')
-          .attr('text-anchor', 'middle')
-          .attr('dy', '0.35em')
-          .attr('fill', style.color)
-          .attr('font-size', '8px')
-          .text(style.scoreText)
-      }
+      matchLinksData.push({
+        source: srcEdge.sourceId,
+        target: persona.id,
+        score: srcEdge.score,
+        srcX: sourceNode.x - 50,
+        srcY: sourceNode.y - 25,
+        tgtX: persona.x,
+        tgtY: persona.y
+      })
     }
   }
 
+  // 绘制匹配连线(使用 data binding)
+  const matchLinksG = contentG.append('g').attr('class', 'match-links')
+
+  matchLinksG.selectAll('.match-link')
+    .data(matchLinksData)
+    .join('path')
+    .attr('class', 'match-link')
+    .attr('fill', 'none')
+    .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
+    .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
+    .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
+    .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
+    .attr('d', d => {
+      const midY = (d.srcY + d.tgtY) / 2
+      return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
+    })
+
+  // 绘制分数标签(使用 data binding)
+  const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
+
+  const scoreGroups = matchLinksG.selectAll('.match-score')
+    .data(scoreData)
+    .join('g')
+    .attr('class', 'match-score')
+    .attr('transform', d => {
+      const midX = (d.srcX + d.tgtX) / 2
+      const midY = (d.srcY + d.tgtY) / 2
+      return `translate(${midX}, ${midY})`
+    })
+
+  scoreGroups.append('rect')
+    .attr('x', -14)
+    .attr('y', -6)
+    .attr('width', 28)
+    .attr('height', 12)
+    .attr('rx', 2)
+    .attr('fill', '#1d232a')
+    .attr('opacity', 0.9)
+
+  scoreGroups.append('text')
+    .attr('text-anchor', 'middle')
+    .attr('dy', '0.35em')
+    .attr('fill', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
+    .attr('font-size', '8px')
+    .text(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
+
   // 绘制匹配节点
   const matchNodesG = contentG.append('g').attr('class', 'match-nodes')
 
@@ -399,6 +406,9 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
         .attr('stroke', 'rgba(255,255,255,0.5)')
         .attr('stroke-width', 1)
     }
+
+    // 保存匹配节点位置(统一存入 nodeElements)
+    nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
 
   // 匹配节点标签(使用统一配置)
@@ -422,11 +432,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
 // 匹配节点点击处理
 function handleMatchNodeClick(event, d) {
   event.stopPropagation()
-  // 在人设树中选中对应节点
-  const personaNodeId = d.id.replace('人设:', '')
-  // 可以触发一个事件让左边的人设树高亮
-  console.log('点击匹配节点:', d.id, d.name)
-  // TODO: 联动人设树
+  store.selectNode(d)
 }
 
 // 适应视图(自动缩放以显示全部内容)
@@ -471,57 +477,28 @@ function zoomToNode(nodeId) {
   )
 }
 
-// 更新选中/高亮状态
-function updateSelection() {
-  const svg = d3.select(svgRef.value)
-  const hasHighlight = store.highlightedPostNodeIds.size > 0
-
-  svg.selectAll('.tree-node')
-    .classed('selected', d => store.selectedPostNodeId === d.data.id)
-    .classed('highlighted', d => store.highlightedPostNodeIds.has(d.data.id))
-    .classed('dimmed', d => hasHighlight && !store.highlightedPostNodeIds.has(d.data.id))
-
-  svg.selectAll('.tree-link')
-    .classed('highlighted', d => {
-      return store.highlightedPostNodeIds.has(d.source.data.id) &&
-             store.highlightedPostNodeIds.has(d.target.data.id)
-    })
-    .classed('dimmed', d => {
-      return hasHighlight && !(store.highlightedPostNodeIds.has(d.source.data.id) &&
-             store.highlightedPostNodeIds.has(d.target.data.id))
-    })
-}
-
-// 清除高亮
-function clearHighlight() {
-  store.clearPostSelection()
-  updateSelection()
+// 更新高亮/置灰状态
+function updateHighlight() {
+  applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
 }
 
 // 点击空白取消激活
 function handleSvgClick(event) {
-  // 检查是否点击了节点或文字,如果不是则取消激活
   const target = event.target
-  const isNode = target.closest('.tree-node')
-  if (!isNode) {
-    clearHighlight()
+  if (!target.closest('.tree-node') && !target.closest('.match-node')) {
+    store.clearSelection()
   }
 }
 
-// 监听选中变化
-watch(() => store.selectedPostNodeId, (nodeId, oldNodeId) => {
+// 监听选中/高亮变化,统一更新
+watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
+  updateHighlight()
   if (nodeId && nodeId !== oldNodeId) {
-    updateSelection()
     zoomToNode(nodeId)
-  } else if (!nodeId) {
-    updateSelection()
   }
 })
 
-// 监听高亮变化
-watch(() => store.highlightedPostNodeIds.size, () => {
-  updateSelection()
-})
+watch(() => store.highlightedNodeIds.size, updateHighlight)
 
 // 监听当前帖子变化,重新渲染树
 watch(() => store.currentPostGraph, () => {

+ 10 - 50
script/visualization/src/components/TreeView.vue

@@ -66,6 +66,7 @@ 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'
+import { applyHighlight } from '../utils/highlight'
 
 const props = defineProps({
   hideHeader: {
@@ -130,12 +131,9 @@ function selectSuggestion() {
 
 // 选中节点(激活)
 function selectNode(node) {
-  store.selectedNodeId = node.id
-  store.highlightedNodeIds = new Set([node.id])
+  store.selectNode(node)
   searchQuery.value = ''
   showSuggestions.value = false
-  updateSelection()
-  zoomToNode(node.id)
 }
 
 // 根据节点数据获取颜色(用于搜索列表)
@@ -150,11 +148,7 @@ let currentRoot = null
 // 处理节点点击
 function handleNodeClick(event, d) {
   event.stopPropagation()
-  const nodeId = d.data.id
-  store.selectedNodeId = nodeId
-  // 清除之前的高亮,等待 GraphView 的游走配置
-  store.highlightedNodeIds = new Set([nodeId])
-  updateSelection()
+  store.selectNode(d)
 }
 
 // 渲染树(缩放模式)
@@ -233,12 +227,7 @@ function renderTree() {
     .selectAll('.tree-node')
     .data(root.descendants())
     .join('g')
-    .attr('class', d => {
-      let cls = 'tree-node'
-      if (store.selectedNodeId === d.data.id) cls += ' selected'
-      if (store.highlightedNodeIds.has(d.data.id)) cls += ' highlighted'
-      return cls
-    })
+    .attr('class', 'tree-node')
     .attr('transform', d => `translate(${d.y},${d.x})`)
     .style('cursor', 'pointer')
     .on('click', handleNodeClick)
@@ -334,54 +323,25 @@ function zoomToNode(nodeId) {
 
 // 更新选中/高亮状态
 function updateSelection() {
-  const svg = d3.select(svgRef.value)
-  const hasHighlight = store.highlightedNodeIds.size > 0
-
-  svg.selectAll('.tree-node')
-    .classed('selected', d => store.selectedNodeId === d.data.id)
-    .classed('highlighted', d => store.highlightedNodeIds.has(d.data.id))
-    .classed('dimmed', d => hasHighlight && !store.highlightedNodeIds.has(d.data.id))
-
-  svg.selectAll('.tree-link')
-    .classed('highlighted', d => {
-      return store.highlightedNodeIds.has(d.source.data.id) &&
-             store.highlightedNodeIds.has(d.target.data.id)
-    })
-    .classed('dimmed', d => {
-      return hasHighlight && !(store.highlightedNodeIds.has(d.source.data.id) &&
-             store.highlightedNodeIds.has(d.target.data.id))
-    })
-}
-
-// 清除高亮
-function clearHighlight() {
-  store.clearSelection()
-  updateSelection()
+  applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
 }
 
 // 点击空白取消激活
 function handleSvgClick(event) {
-  const target = event.target
-  const isNode = target.closest('.tree-node')
-  if (!isNode) {
-    clearHighlight()
+  if (!event.target.closest('.tree-node')) {
+    store.clearSelection()
   }
 }
 
-// 监听选中变化(从外部触发)
+// 监听选中/高亮变化,统一更新
 watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
+  updateSelection()
   if (nodeId && nodeId !== oldNodeId) {
-    updateSelection()
     zoomToNode(nodeId)
-  } else if (!nodeId) {
-    updateSelection()
   }
 })
 
-// 监听高亮变化(从 GraphView 游走结果同步)
-watch(() => store.highlightedNodeIds.size, () => {
-  updateSelection()
-})
+watch(() => store.highlightedNodeIds.size, updateSelection)
 
 // 监听布局变化,过渡结束后重新适应视图
 function handleTransitionEnd(e) {

+ 119 - 76
script/visualization/src/stores/graph.js

@@ -1,17 +1,17 @@
 import { defineStore } from 'pinia'
-import { ref, computed } from 'vue'
+import { ref, reactive, computed, watch } from 'vue'
 
 // eslint-disable-next-line no-undef
-const graphDataRaw = __GRAPH_DATA__  // 由 vite.config.js 注入
+const graphDataRaw = __GRAPH_DATA__
 // eslint-disable-next-line no-undef
-const postGraphListRaw = __POST_GRAPH_LIST__ || []  // 帖子图谱列表
+const postGraphListRaw = __POST_GRAPH_LIST__ || []
 
 console.log('人设图谱 loaded:', !!graphDataRaw)
 console.log('人设节点数:', Object.keys(graphDataRaw?.nodes || {}).length)
 console.log('帖子图谱数:', postGraphListRaw.length)
 
 export const useGraphStore = defineStore('graph', () => {
-  // ==================== 人设图谱数据 ====================
+  // ==================== 数据 ====================
   const graphData = ref(graphDataRaw || { nodes: {}, edges: {}, index: {}, tree: {} })
 
   // ==================== 帖子图谱数据 ====================
@@ -39,99 +39,139 @@ export const useGraphStore = defineStore('graph', () => {
   // 选择帖子
   function selectPost(index) {
     selectedPostIndex.value = index
-    // 清除帖子相关的选中状态
-    selectedPostNodeId.value = null
-    highlightedPostNodeIds.value = new Set()
+    clearSelection()
   }
 
-  // ==================== 帖子树选中状态 ====================
-  const selectedPostNodeId = ref(null)
-  const highlightedPostNodeIds = ref(new Set())
-
-  // 获取帖子节点
-  function getPostNode(nodeId) {
-    return currentPostGraph.value?.nodes?.[nodeId]
+  // ==================== 游走配置 ====================
+  // 使用游走的节点类型前缀
+  const walkNodeTypes = ref(['人设:'])
+
+  const walkSteps = ref(2)
+  const stepConfigs = reactive([
+    { edgeTypes: [], minScore: 0 },  // 第1步:初始化时全选
+    { edgeTypes: ['属于'], minScore: 0 },
+    { edgeTypes: ['属于'], minScore: 0 },
+    { edgeTypes: ['属于'], minScore: 0 },
+    { edgeTypes: ['属于'], minScore: 0 }
+  ])
+
+  // 判断节点是否使用游走
+  function shouldWalk(nodeId) {
+    return walkNodeTypes.value.some(prefix => nodeId.startsWith(prefix))
   }
 
-  // 选中帖子节点
-  function selectPostNode(nodeId) {
-    selectedPostNodeId.value = nodeId
-    highlightedPostNodeIds.value = new Set([nodeId])
-  }
+  // 所有边类型
+  const allEdgeTypes = computed(() => {
+    const types = new Set()
+    for (const edge of Object.values(graphData.value.edges || {})) {
+      if (edge.type) types.add(edge.type)
+    }
+    return Array.from(types)
+  })
 
-  // 清除帖子选中
-  function clearPostSelection() {
-    selectedPostNodeId.value = null
-    highlightedPostNodeIds.value = new Set()
-  }
+  // 当前激活的边类型(从所有步骤的配置中收集)
+  const activeEdgeTypes = computed(() => {
+    const types = new Set()
+    for (let i = 0; i < walkSteps.value; i++) {
+      for (const t of stepConfigs[i].edgeTypes) {
+        types.add(t)
+      }
+    }
+    return types
+  })
 
-  // 当前选中的节点
-  const selectedNodeId = ref(null)
+  // 初始化第1步为全选
+  watch(allEdgeTypes, (types) => {
+    if (stepConfigs[0].edgeTypes.length === 0) {
+      stepConfigs[0].edgeTypes = [...types]
+    }
+  }, { immediate: true })
+
+  // 游走时记录的边(供 GraphView 渲染用)
+  const walkedEdges = ref([])
+
+  // 游走的边集合(供高亮判断用,格式:"sourceId->targetId")
+  const walkedEdgeSet = computed(() => {
+    const set = new Set()
+    for (const e of walkedEdges.value) {
+      set.add(`${e.source}->${e.target}`)
+    }
+    return set
+  })
 
-  // 高亮的节点ID集合
+  // ==================== 统一的选中/高亮状态 ====================
+  const selectedNodeId = ref(null)
   const highlightedNodeIds = ref(new Set())
 
   // 获取节点
   function getNode(nodeId) {
-    return graphData.value.nodes[nodeId]
+    return graphData.value.nodes[nodeId] || currentPostGraph.value?.nodes?.[nodeId]
   }
 
-  // 获取邻居节点
-  function getNeighbors(nodeId, direction = 'both') {
+  // 根据配置获取过滤后的邻居(沿出边游走)
+  function getFilteredNeighbors(nodeId, config) {
     const neighbors = []
-    const seen = new Set()
     const index = graphData.value.index
+    const outEdges = index.outEdges?.[nodeId] || {}
 
-    if (direction === 'out' || direction === 'both') {
-      const outEdges = index.outEdges?.[nodeId] || {}
-      for (const [edgeType, targets] of Object.entries(outEdges)) {
-        for (const t of targets) {
-          if (!seen.has(t.target)) {
-            seen.add(t.target)
-            neighbors.push({
-              nodeId: t.target,
-              edgeType,
-              direction: 'out',
-              score: t.score
-            })
-          }
+    for (const [edgeType, targets] of Object.entries(outEdges)) {
+      if (!config.edgeTypes.includes(edgeType)) continue
+      for (const t of targets) {
+        if ((t.score || 0) >= config.minScore) {
+          neighbors.push({ nodeId: t.target, edgeType, score: t.score })
         }
       }
     }
+    return neighbors
+  }
 
-    if (direction === 'in' || direction === 'both') {
-      const inEdges = index.inEdges?.[nodeId] || {}
-      for (const [edgeType, sources] of Object.entries(inEdges)) {
-        for (const s of sources) {
-          if (!seen.has(s.source)) {
-            seen.add(s.source)
-            neighbors.push({
-              nodeId: s.source,
-              edgeType,
-              direction: 'in',
-              score: s.score
-            })
+  // 执行游走(仅人设节点)
+  function executeWalk(startNodeId) {
+    const visited = new Set([startNodeId])
+    let currentFrontier = new Set([startNodeId])
+    const edges = []
+
+    for (let step = 0; step < walkSteps.value; step++) {
+      const config = stepConfigs[step]
+      const nextFrontier = new Set()
+
+      for (const nodeId of currentFrontier) {
+        for (const n of getFilteredNeighbors(nodeId, config)) {
+          if (!visited.has(n.nodeId)) {
+            visited.add(n.nodeId)
+            nextFrontier.add(n.nodeId)
+            edges.push({ source: nodeId, target: n.nodeId, type: n.edgeType, score: n.score })
           }
         }
       }
+
+      currentFrontier = nextFrontier
+      if (currentFrontier.size === 0) break
     }
 
-    return neighbors
+    walkedEdges.value = edges
+    return visited
   }
 
-  // 选中节点
-  function selectNode(nodeId) {
+  // 选中节点(根据节点类型决定激活逻辑)
+  function selectNode(nodeOrId) {
+    const nodeId = typeof nodeOrId === 'string' ? nodeOrId : (nodeOrId.data?.id || nodeOrId.id)
     selectedNodeId.value = nodeId
 
-    // 更新高亮节点(邻居)
-    const neighbors = getNeighbors(nodeId)
-    highlightedNodeIds.value = new Set(neighbors.map(n => n.nodeId))
+    // 根据配置决定是否执行游走
+    if (shouldWalk(nodeId)) {
+      highlightedNodeIds.value = executeWalk(nodeId)
+    } else {
+      highlightedNodeIds.value = new Set([nodeId])
+      walkedEdges.value = []
+    }
   }
 
   // 清除选中
   function clearSelection() {
     selectedNodeId.value = null
     highlightedNodeIds.value = new Set()
+    walkedEdges.value = []
   }
 
   // 计算属性:当前选中节点的数据
@@ -158,28 +198,31 @@ export const useGraphStore = defineStore('graph', () => {
   }
 
   return {
-    // 人设图谱
+    // 数据
     graphData,
-    selectedNodeId,
-    highlightedNodeIds,
-    selectedNode,
     treeData,
-    getNode,
-    getNeighbors,
-    selectNode,
-    clearSelection,
-    // 帖子图谱
     postGraphList,
     postList,
     selectedPostIndex,
     currentPostGraph,
     postTreeData,
     selectPost,
-    selectedPostNodeId,
-    highlightedPostNodeIds,
-    getPostNode,
-    selectPostNode,
-    clearPostSelection,
+    // 游走配置
+    walkNodeTypes,
+    walkSteps,
+    stepConfigs,
+    allEdgeTypes,
+    activeEdgeTypes,
+    walkedEdges,
+    walkedEdgeSet,
+    shouldWalk,
+    // 选中/高亮
+    selectedNodeId,
+    highlightedNodeIds,
+    selectedNode,
+    getNode,
+    selectNode,
+    clearSelection,
     // 布局
     expandedPanel,
     expandPanel,

+ 23 - 8
script/visualization/src/style.css

@@ -53,18 +53,33 @@
     stroke-width: 2;
   }
 
-  /* 有高亮时,非高亮节点置灰 */
-  .tree-node.dimmed circle,
-  .tree-node.dimmed rect {
-    opacity: 0.2;
+  /* ========== 统一的置灰样式 ========== */
+  /* 所有节点置灰 */
+  .tree-node.dimmed,
+  .match-node.dimmed,
+  .graph-node.dimmed {
+    opacity: 0.15;
+    pointer-events: none;
+  }
+
+  /* 所有边置灰 */
+  .tree-link.dimmed,
+  .match-link.dimmed,
+  .graph-link.dimmed {
+    stroke-opacity: 0.08 !important;
   }
 
-  .tree-node.dimmed text {
-    opacity: 0.2;
+  /* 分数标签置灰 */
+  .match-score.dimmed {
+    opacity: 0.15;
   }
 
-  .tree-link.dimmed {
-    stroke-opacity: 0.1;
+  /* ========== 统一的高亮样式 ========== */
+  .tree-link.highlighted,
+  .match-link.highlighted,
+  .graph-link.highlighted {
+    stroke-opacity: 0.8 !important;
+    stroke-width: 2 !important;
   }
 
   .tree-node text {

+ 70 - 0
script/visualization/src/utils/highlight.js

@@ -0,0 +1,70 @@
+// 统一的高亮/置灰工具
+import * as d3 from 'd3'
+
+/**
+ * 获取节点ID(兼容 d3.hierarchy 和普通对象)
+ */
+function getNodeId(d) {
+  return d.data?.id || d.id
+}
+
+/**
+ * 获取边的 source 和 target ID
+ */
+function getEdgeIds(d) {
+  const sourceId = d.source?.data?.id || d.source?.id || d.source
+  const targetId = d.target?.data?.id || d.target?.id || d.target
+  return { sourceId, targetId }
+}
+
+/**
+ * 检查边是否在游走的边集合中
+ */
+function isEdgeInWalked(d, walkedEdgeSet) {
+  if (!walkedEdgeSet) return false
+  const { sourceId, targetId } = getEdgeIds(d)
+  // 检查正向和反向
+  return walkedEdgeSet.has(`${sourceId}->${targetId}`) || walkedEdgeSet.has(`${targetId}->${sourceId}`)
+}
+
+/**
+ * 应用高亮/置灰效果
+ * @param {SVGElement} svgEl - SVG DOM 元素
+ * @param {Set} highlightedIds - 高亮的节点ID集合
+ * @param {Set} walkedEdgeSet - 游走的边集合(格式:"sourceId->targetId")
+ * @param {string} selectedId - 选中的节点ID
+ */
+export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, selectedId = null) {
+  if (!svgEl) return
+
+  const svg = d3.select(svgEl)
+  const hasHighlight = highlightedIds.size > 0
+
+  // 所有节点:在 highlightedIds 中的保持,否则置灰
+  svg.selectAll('.tree-node, .match-node, .graph-node')
+    .classed('selected', d => getNodeId(d) === selectedId)
+    .classed('dimmed', d => hasHighlight && !highlightedIds.has(getNodeId(d)))
+
+  // 所有边:在 walkedEdgeSet 中的保持,否则置灰
+  svg.selectAll('.tree-link, .graph-link, .match-link, .match-score')
+    .classed('dimmed', function(d) {
+      if (!hasHighlight) return false
+      if (d && walkedEdgeSet) {
+        return !isEdgeInWalked(d, walkedEdgeSet)
+      }
+      // fallback:检查两端节点是否都高亮
+      if (d) {
+        const { sourceId, targetId } = getEdgeIds(d)
+        return !highlightedIds.has(sourceId) || !highlightedIds.has(targetId)
+      }
+      return true
+    })
+
+  // 选中节点的边框
+  svg.selectAll('.match-node').each(function(d) {
+    const isSelected = d.id === selectedId
+    d3.select(this).select('.tree-shape')
+      .attr('stroke', isSelected ? '#fff' : 'rgba(255,255,255,0.5)')
+      .attr('stroke-width', isSelected ? 2 : 1)
+  })
+}