Ver código fonte

Merge remote-tracking branch 'origin/how_1202_v1' into how_1202_v2

yangxiaohui 1 dia atrás
pai
commit
1944e55ecb

+ 6 - 26
script/data_processing/build_persona_graph.py

@@ -207,17 +207,6 @@ def extract_category_nodes_from_pattern(
             if isinstance(value, dict):
                 current_path = parent_path + [key]
 
-                # 构建节点来源(只收集当前节点的特征)
-                node_sources = []
-                if "特征列表" in value:
-                    for feature in value["特征列表"]:
-                        source = {
-                            "pointName": feature.get("所属点", ""),
-                            "pointDesc": feature.get("点描述", ""),
-                            "postId": feature.get("帖子id", "")
-                        }
-                        node_sources.append(source)
-
                 # 收集帖子ID列表(递归收集当前节点及所有子节点的帖子ID,去重)
                 all_sources = collect_sources_recursively(value)
                 unique_post_ids = list(set(s.get("postId", "") for s in all_sources if s.get("postId")))
@@ -232,8 +221,7 @@ def extract_category_nodes_from_pattern(
                     detail={
                         "parentPath": parent_path.copy(),
                         "postIds": unique_post_ids,
-                        "postCount": len(unique_post_ids),
-                        "sources": node_sources
+                        "postCount": len(unique_post_ids)
                     }
                 )
 
@@ -258,7 +246,7 @@ def extract_tag_nodes_from_pattern(
         { nodeId: nodeData }
     """
     nodes = {}
-    tag_map = {}  # 用于合并同名标签: tagId -> { sources, postIds, parentPath }
+    tag_map = {}  # 用于合并同名标签: tagId -> { postIds, parentPath }
 
     if dimension_key not in pattern_data:
         return nodes
@@ -272,25 +260,18 @@ def extract_tag_nodes_from_pattern(
                 if not tag_name:
                     continue
 
-                source = {
-                    "pointName": feature.get("所属点", ""),
-                    "pointDesc": feature.get("点描述", ""),
-                    "postId": feature.get("帖子id", "")
-                }
-
+                post_id = feature.get("帖子id", "")
                 tag_id = build_node_id("人设", dimension_name, "标签", tag_name)
 
                 if tag_id not in tag_map:
                     tag_map[tag_id] = {
                         "name": tag_name,
-                        "sources": [],
                         "postIds": set(),
                         "parentPath": parent_path.copy()
                     }
 
-                tag_map[tag_id]["sources"].append(source)
-                if source["postId"]:
-                    tag_map[tag_id]["postIds"].add(source["postId"])
+                if post_id:
+                    tag_map[tag_id]["postIds"].add(post_id)
 
         # 递归处理子节点
         for key, value in node.items():
@@ -313,8 +294,7 @@ def extract_tag_nodes_from_pattern(
             detail={
                 "parentPath": tag_info["parentPath"],
                 "postIds": list(tag_info["postIds"]),
-                "postCount": len(tag_info["postIds"]),
-                "sources": tag_info["sources"]
+                "postCount": len(tag_info["postIds"])
             }
         )
 

+ 170 - 26
script/visualization/src/components/DerivationView.vue

@@ -458,6 +458,70 @@ function computeLocalPath(fromId, toId) {
   return { nodes: pathNodes, edges: pathEdges }
 }
 
+// 基于已高亮的路径计算从 fromId 到 toId 的子路径
+function computePathInHighlighted(fromId, toId, highlightedNodes, highlightedEdges) {
+  // hover 节点必须在已高亮的路径中
+  if (!highlightedNodes.has(fromId)) return { nodes: new Set(), edges: new Set() }
+
+  // 构建邻接表(只包含已高亮的边)
+  const adj = new Map()
+  for (const link of linksData) {
+    const srcId = typeof link.source === 'object' ? link.source.id : link.source
+    const tgtId = typeof link.target === 'object' ? link.target.id : link.target
+    const edgeKey = `${srcId}->${tgtId}`
+    const edgeKeyRev = `${tgtId}->${srcId}`
+
+    // 只使用已高亮的边
+    if (!highlightedEdges.has(edgeKey) && !highlightedEdges.has(edgeKeyRev)) continue
+
+    if (!adj.has(srcId)) adj.set(srcId, [])
+    if (!adj.has(tgtId)) adj.set(tgtId, [])
+    adj.get(srcId).push({ neighbor: tgtId, edge: link })
+    adj.get(tgtId).push({ neighbor: srcId, edge: link })
+  }
+
+  // BFS 找路径
+  const visited = new Set([fromId])
+  const parent = new Map()
+  const parentEdge = new Map()
+  const queue = [fromId]
+
+  while (queue.length > 0) {
+    const curr = queue.shift()
+    if (curr === toId) break
+
+    for (const { neighbor, edge } of (adj.get(curr) || [])) {
+      if (!visited.has(neighbor)) {
+        visited.add(neighbor)
+        parent.set(neighbor, curr)
+        parentEdge.set(neighbor, edge)
+        queue.push(neighbor)
+      }
+    }
+  }
+
+  // 回溯路径
+  const pathNodes = new Set()
+  const pathEdges = new Set()
+
+  if (visited.has(toId)) {
+    let curr = toId
+    while (curr) {
+      pathNodes.add(curr)
+      const edge = parentEdge.get(curr)
+      if (edge) {
+        const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
+        const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
+        pathEdges.add(`${srcId}->${tgtId}`)
+        pathEdges.add(`${tgtId}->${srcId}`)
+      }
+      curr = parent.get(curr)
+    }
+  }
+
+  return { nodes: pathNodes, edges: pathEdges }
+}
+
 // 基于当前显示的边计算从某节点出发的路径(使用统一的 findPaths)
 function computeLocalHoverPath(nodeId) {
   if (!linksData || linksData.length === 0) return { nodes: new Set(), edges: new Set() }
@@ -573,7 +637,7 @@ function handleLockClick() {
   }
 }
 
-// hover 节点处理(使用本地路径计算,基于过滤后的边
+// hover 节点处理(基于当前高亮的路径查找
 function handleNodeHover(event, d) {
   // 只有在有选中节点时才触发路径高亮
   if (!selectedNodeId.value) return
@@ -581,8 +645,16 @@ function handleNodeHover(event, d) {
   // 不处理选中节点自身
   if (d.id === selectedNodeId.value) return
 
-  // 使用本地路径计算(基于当前显示的边)
-  const { nodes: pathNodes, edges: pathEdges } = computeLocalPath(d.id, selectedNodeId.value)
+  // 基于当前高亮的路径查找(只在已高亮的节点和边中查找)
+  const { nodes: pathNodes, edges: pathEdges } = computePathInHighlighted(
+    d.id,
+    selectedNodeId.value,
+    selectedPathNodes.value,
+    selectedPathEdges.value
+  )
+
+  // 如果找不到路径(hover 节点不在高亮路径中),不做任何处理
+  if (pathNodes.size === 0) return
 
   // 更新 store 状态用于联动
   store.hoverNodeId = d.id
@@ -615,23 +687,19 @@ function handleNodeHoverOut() {
 
   store.clearHover()
 
-  // 恢复到选中节点的路径高亮(使用本地路径计算)
-  if (selectedNodeId.value && !store.lockedHoverNodeId) {
-    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
-    store.hoverPathNodes = pathNodes
-    store.hoverPathEdges = pathEdges
+  if (store.lockedHoverNodeId) {
+    // 锁定状态:clearHover 已恢复锁定路径,需要设置 source 并重新高亮
     store.hoverSource = 'derivation'
     applyDerivationHighlight()
-  }
-
-  if (store.lockedHoverNodeId) {
-    // 恢复锁定状态,在锁定节点上显示按钮
+    // 在锁定节点上显示解锁按钮
     nodeSelection.each(function(d) {
       if (d.id === store.lockedHoverNodeId) {
         showLockButton(this, true)
       }
     })
   } else {
+    // 非锁定状态:只恢复推导图谱自己的本地高亮(不修改 store,让其他图自己恢复)
+    applyDerivationHighlight()
     hideLockButton()
   }
 }
@@ -674,6 +742,22 @@ function applySelectedHighlight() {
   store.hoverPathNodes = pathNodes
   store.hoverPathEdges = pathEdges
   store.hoverSource = 'derivation'
+
+  // 设置推导图谱高亮(用于相关图合并)- 获取完整边数据
+  const highlightEdges = linksData.filter(link => {
+    const srcId = typeof link.source === 'object' ? link.source.id : link.source
+    const tgtId = typeof link.target === 'object' ? link.target.id : link.target
+    const edgeKey = `${srcId}->${tgtId}`
+    const edgeKeyRev = `${tgtId}->${srcId}`
+    return pathEdges.has(edgeKey) || pathEdges.has(edgeKeyRev)
+  }).map(link => ({
+    source: typeof link.source === 'object' ? link.source.id : link.source,
+    target: typeof link.target === 'object' ? link.target.id : link.target,
+    type: link.type,
+    score: link.score
+  }))
+  store.setDerivationHighlight(pathNodes, highlightEdges)
+
   applyDerivationHighlight()
 }
 
@@ -683,6 +767,7 @@ function clearHighlightState() {
   selectedPathEdges.value = new Set()
   store.clearHover()
   store.clearAllLocked()
+  store.clearDerivationHighlight()
   if (nodeSelection && linkSelection) {
     clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
     // 恢复箭头(根据边类型)
@@ -730,11 +815,17 @@ function applyDerivationHighlight() {
     }
   }
 
+  // 先清除所有残留的高亮类
+  nodeSelection
+    .classed('highlighted', false)
+
   // 节点高亮
+  // selected: 本地选中节点或全局选中节点(来自其他视图)
+  const activeNodeId = selectedNodeId.value || store.selectedNodeId
   nodeSelection
     .classed('dimmed', d => !allPathNodes.has(d.id))
     .classed('locked-path', d => lockedPathNodes && lockedPathNodes.has(d.id) && !pathNodes.has(d.id))
-    .classed('selected', d => d.id === store.hoverNodeId)
+    .classed('selected', d => d.id === activeNodeId)
 
   // 边高亮(使用 pathEdges 判断,而不是节点)
   linkSelection.each(function(d) {
@@ -1053,6 +1144,49 @@ function fitToView() {
   }
 }
 
+// 缩放到高亮子图(完整显示路径上的所有节点)
+function zoomToSubgraph(pathNodeIds) {
+  if (!mainG || !svgRef.value || !containerRef.value || !currentZoom) return
+  if (!pathNodeIds || pathNodeIds.size === 0) return
+
+  // 找到路径上所有节点的位置
+  const pathNodes = nodesData.filter(n => pathNodeIds.has(n.id) && n.x !== undefined && n.y !== undefined)
+  if (pathNodes.length === 0) return
+
+  const svg = d3.select(svgRef.value)
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+
+  // 计算边界框
+  const xs = pathNodes.map(n => n.x)
+  const ys = pathNodes.map(n => n.y)
+  const minX = Math.min(...xs)
+  const maxX = Math.max(...xs)
+  const minY = Math.min(...ys)
+  const maxY = Math.max(...ys)
+
+  const boundsWidth = maxX - minX
+  const boundsHeight = maxY - minY
+  const centerX = (minX + maxX) / 2
+  const centerY = (minY + maxY) / 2
+
+  // 计算缩放比例(留出边距)
+  const padding = 60
+  const scale = Math.min(
+    (width - padding * 2) / (boundsWidth || 1),
+    (height - padding * 2) / (boundsHeight || 1),
+    1.5  // 最大缩放限制
+  )
+
+  const tx = width / 2 - centerX * scale
+  const ty = height / 2 - centerY * scale
+
+  // 平滑过渡到目标位置
+  svg.transition()
+    .duration(500)
+    .call(currentZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale))
+}
+
 // 拖拽函数
 function dragstarted(event, d) {
   if (!event.active) simulation.alphaTarget(0.3).restart()
@@ -1110,28 +1244,38 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId, () => store.hov
 })
 
 // 监听外部节点选中(联动:PostTreeView 点击节点时同步激活推导图谱)
-watch(() => store.selectedNodeId, (newId) => {
+watch(() => store.selectedNodeId, (newId, oldId) => {
   if (!nodeSelection || !linkSelection) return
 
-  // 检查该节点是否在推导图谱中
+  // 如果是从推导图谱内部点击触发的,不重复处理
+  if (selectedNodeId.value === newId) return
+
+  // 先清除旧的推导高亮(统一清除)
+  if (oldId && selectedNodeId.value) {
+    selectedNodeId.value = null
+    selectedPathNodes.value = new Set()
+    selectedPathEdges.value = new Set()
+    store.clearDerivationHighlight()
+    clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
+    linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
+  }
+
+  // 检查新节点是否在推导图谱中
   const nodeInGraph = nodesData.find(n => n.id === newId)
 
   if (nodeInGraph) {
-    // 节点在推导图谱中,激活并显示高亮(使用本地路径计算)
+    // 节点在推导图谱中,激活并显示高亮
     selectedNodeId.value = newId
-    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(newId)
-    selectedPathNodes.value = pathNodes
-    selectedPathEdges.value = pathEdges
-    store.hoverPathNodes = pathNodes
-    store.hoverPathEdges = pathEdges
-    store.hoverSource = 'derivation'
-    applyDerivationHighlight()
-  } else {
-    // 节点不在推导图谱中,清除选中状态
+    applySelectedHighlight()
+    // 缩放到高亮子图(完整显示路径)
+    zoomToSubgraph(selectedPathNodes.value)
+  } else if (newId) {
+    // 节点不在推导图谱中,清除本地状态但不影响其他图
     selectedNodeId.value = null
     selectedPathNodes.value = new Set()
     selectedPathEdges.value = new Set()
-    clearHighlightState()
+    clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
+    linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
   }
 })
 

+ 14 - 8
script/visualization/src/components/DetailPanel.vue

@@ -18,8 +18,15 @@
       <div v-if="store.selectedNode" class="space-y-3">
         <div class="flex items-center gap-2">
           <span
-            class="w-2.5 h-2.5 rounded-full shrink-0"
-            :style="{ backgroundColor: nodeColor }"
+            class="w-2.5 h-2.5 shrink-0"
+            :class="nodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
+            :style="{
+              ...(nodeStyle.hollow
+                ? { backgroundColor: 'transparent', border: '2px solid ' + nodeStyle.color }
+                : { backgroundColor: nodeStyle.color }),
+              filter: 'brightness(1.2)',
+              boxShadow: '0 0 4px ' + nodeStyle.color
+            }"
           ></span>
           <h4 class="text-primary font-medium truncate">{{ store.selectedNode.name }}</h4>
         </div>
@@ -61,7 +68,7 @@
 <script setup>
 import { computed, h } from 'vue'
 import { useGraphStore } from '../stores/graph'
-import { dimColors } from '../config/nodeStyle'
+import { getNodeStyle } from '../config/nodeStyle'
 import { edgeTypeColors } from '../config/edgeStyle'
 
 const store = useGraphStore()
@@ -69,11 +76,10 @@ const store = useGraphStore()
 // 是否有选中内容
 const hasSelection = computed(() => store.selectedNode || store.selectedEdge)
 
-// 节点颜色
-const nodeColor = computed(() => {
-  if (!store.selectedNode) return '#888'
-  const dim = store.selectedNode.dimension
-  return dimColors[dim] || '#888'
+// 节点样式(统一使用 getNodeStyle)
+const nodeStyle = computed(() => {
+  if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
+  return getNodeStyle(store.selectedNode)
 })
 
 // 边颜色

+ 62 - 37
script/visualization/src/components/GraphView.vue

@@ -201,22 +201,23 @@ function renderGraph() {
   svg.attr('viewBox', `0 0 ${width} ${height}`)
 
   // 中心节点(选中节点,或边的第一个端点)
-  const centerNodeId = store.selectedNodeId || Array.from(store.highlightedNodeIds)[0]
+  const centerNodeId = store.selectedNodeId || Array.from(store.mergedHighlightNodes)[0]
 
-  // 准备节点和边数据
+  // 准备节点和边数据(使用 store 合并后的高亮状态)
   const nodes = []
   const links = []
   const nodeSet = new Set()
+  const edgeSet = new Set()
 
-  // 显示所有高亮节点
-  for (const nodeId of store.highlightedNodeIds) {
+  // 显示所有合并后的高亮节点
+  for (const nodeId of store.mergedHighlightNodes) {
     const nodeData = store.getNode(nodeId)
-    if (nodeData) {
+    if (nodeData && !nodeSet.has(nodeId)) {
       nodes.push({
         id: nodeId,
         ...nodeData,
         isCenter: nodeId === centerNodeId,
-        isHighlighted: store.highlightedNodeIds.size > 1
+        isHighlighted: true
       })
       nodeSet.add(nodeId)
     }
@@ -225,12 +226,14 @@ function renderGraph() {
   // 如果没有节点,不渲染
   if (nodes.length === 0) return
 
-  // 使用游走时记录的边(只显示两端节点都存在的边)
-  // 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
-  const edges = store.postWalkedEdges.length > 0 ? store.postWalkedEdges : store.walkedEdges
-  for (const edge of edges) {
+  // 使用合并后的高亮边(只显示两端节点都存在的边)
+  for (const edge of store.mergedHighlightEdges) {
     if (nodeSet.has(edge.source) && nodeSet.has(edge.target)) {
-      links.push({ ...edge })
+      const edgeKey = `${edge.source}->${edge.target}`
+      if (!edgeSet.has(edgeKey)) {
+        links.push({ ...edge })
+        edgeSet.add(edgeKey)
+      }
     }
   }
 
@@ -338,7 +341,7 @@ function renderGraph() {
       if (store.lockedHoverNodeId) {
         // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
         // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
-        applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, null)
+        applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, null, store.selectedNodeId)
         // 在锁定节点上显示解锁按钮
         graphNodeSelection.each(function(d) {
           if (d.id === store.lockedHoverNodeId) {
@@ -530,20 +533,24 @@ function handleSvgClick(event) {
   }
 }
 
-// 统一高亮更新
+// 统一高亮更新(使用合并后的高亮状态)
 function updateHighlight() {
-  const edgeSet = store.walkedEdgeSet.size > 0 ? store.walkedEdgeSet : store.postWalkedEdgeSet
-  applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
+  // 构建合并后的边集合
+  const mergedEdgeSet = new Set()
+  for (const edge of store.mergedHighlightEdges) {
+    mergedEdgeSet.add(`${edge.source}->${edge.target}`)
+  }
+  applyHighlight(svgRef.value, store.mergedHighlightNodes, mergedEdgeSet, store.selectedNodeId)
 }
 
 // 恢复锁定的 hover 状态(重新渲染后调用)
 function restoreLockedHover() {
   if (!store.lockedHoverNodeId || !graphNodeSelection) return
 
-  // 恢复高亮效果(传入锁定路径)
+  // 恢复高亮效果(传入锁定路径和选中节点
   if (store.hoverPathNodes.size > 0) {
     const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
-    applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath)
+    applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath, store.selectedNodeId)
   }
 
   // 恢复锁定按钮:找到锁定节点的 DOM 元素
@@ -568,35 +575,53 @@ watch(() => store.selectedEdgeId, () => {
 // 监听高亮节点集合变化
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
+// 监听合并高亮变化(各图高亮变化时自动重新渲染)
+watch([() => store.mergedHighlightNodes.size, () => store.mergedHighlightEdges.length], () => {
+  nextTick(() => {
+    renderGraph()
+    // 渲染后缩放到所有高亮节点
+    nextTick(() => {
+      if (store.mergedHighlightNodes.size > 0) {
+        zoomToPathNodes(store.mergedHighlightNodes)
+      }
+    })
+  })
+})
+
 // 监听 hover 状态变化(用于左右联动)
 watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   if (!graphNodeSelection || !graphLinkSelection) return
 
-  if (store.hoverPathNodes.size > 0) {
-    // 应用 hover 高亮(支持嵌套:传入锁定路径)
-    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
-    applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath)
-
-    // 如果是从 PostTreeView 触发的,缩放到显示完整路径,并显示锁定按钮
-    if (store.hoverSource === 'post-tree') {
-      zoomToPathNodes(store.hoverPathNodes)
+  // hover 结束时,无论来源是什么,都恢复合并后的高亮
+  if (store.hoverPathNodes.size === 0) {
+    updateHighlight()
+    // 缩放回合并后的高亮节点
+    if (store.mergedHighlightNodes.size > 0) {
+      zoomToPathNodes(store.mergedHighlightNodes)
     }
-
-    // 在对应节点上显示锁定按钮(无论来源)
-    if (store.hoverNodeId) {
-      graphNodeSelection.each(function(d) {
-        if (d.id === store.hoverNodeId) {
-          showLockButton(this)
-        }
-      })
-    }
-  } else {
-    // 清除 hover,恢复原有高亮
-    clearHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection)
     // 如果没有锁定,隐藏按钮
     if (!store.lockedHoverNodeId) {
       hideLockButton()
     }
+    return
+  }
+
+  // 应用 hover 高亮(支持嵌套:传入锁定路径和选中节点)
+  const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+  applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath, store.selectedNodeId)
+
+  // 如果是从其他视图触发的 hover,缩放到显示完整路径
+  if (store.hoverSource === 'post-tree' || store.hoverSource === 'derivation') {
+    zoomToPathNodes(store.hoverPathNodes)
+  }
+
+  // 在对应节点上显示锁定按钮(无论来源)
+  if (store.hoverNodeId) {
+    graphNodeSelection.each(function(d) {
+      if (d.id === store.hoverNodeId) {
+        showLockButton(this)
+      }
+    })
   }
 })
 

+ 237 - 57
script/visualization/src/components/PostTreeView.vue

@@ -135,13 +135,18 @@
               <div class="flex items-center gap-1.5">
                 <!-- hover 标识 -->
                 <span v-if="store.hoverNode" class="text-[9px] text-warning/60">[hover]</span>
-                <!-- 节点样式:空心(帖子域)或实心(人设域) -->
+                <!-- 节点样式:空心(帖子域)或实心(人设域),激活状态有发光效果 -->
                 <span
                   class="w-2 h-2 shrink-0"
                   :class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
-                  :style="displayNodeStyle.hollow
-                    ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
-                    : { backgroundColor: displayNodeStyle.color }"
+                  :style="{
+                    ...(displayNodeStyle.hollow
+                      ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
+                      : { backgroundColor: displayNodeStyle.color }),
+                    ...(store.selectedNode && !store.hoverNode
+                      ? { filter: 'brightness(1.2)', boxShadow: '0 0 4px ' + displayNodeStyle.color }
+                      : {})
+                  }"
                 ></span>
                 <span class="text-primary font-medium text-[11px] break-all">{{ displayNode.name }}</span>
               </div>
@@ -373,8 +378,8 @@ const props = defineProps({
 
 const emit = defineEmits(['update:matchListCollapsed'])
 
-// 不需要显示的节点字段
-const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy']
+// 不需要显示的节点字段(D3相关字段和children)
+const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy', 'children', 'id', 'data', 'parent', 'depth', 'height']
 
 const store = useGraphStore()
 
@@ -562,32 +567,62 @@ function getNodeColor(node) {
   return '#888'
 }
 
-// 节点的入边列表(按分数降序)
+// 节点的入边列表(按分数降序,同时查找帖子图谱和人设图谱
 const nodeInEdges = computed(() => {
   if (!displayNode.value) return []
   const nodeId = displayNode.value.id || displayNode.value.data?.id
   if (!nodeId) return []
 
+  const edges = []
+
+  // 从帖子图谱查找
   const postGraph = store.currentPostGraph
-  if (!postGraph?.edges) return []
+  if (postGraph?.edges) {
+    for (const e of Object.values(postGraph.edges)) {
+      if (e.target === nodeId) edges.push(e)
+    }
+  }
+
+  // 从人设图谱查找(如果是人设节点)
+  if (nodeId.startsWith('人设:')) {
+    const personaEdges = store.graphData?.edges
+    if (personaEdges) {
+      for (const e of Object.values(personaEdges)) {
+        if (e.target === nodeId) edges.push(e)
+      }
+    }
+  }
 
-  return Object.values(postGraph.edges)
-    .filter(e => e.target === nodeId)
-    .sort((a, b) => (b.score || 0) - (a.score || 0))
+  return edges.sort((a, b) => (b.score || 0) - (a.score || 0))
 })
 
-// 节点的出边列表(按分数降序)
+// 节点的出边列表(按分数降序,同时查找帖子图谱和人设图谱
 const nodeOutEdges = computed(() => {
   if (!displayNode.value) return []
   const nodeId = displayNode.value.id || displayNode.value.data?.id
   if (!nodeId) return []
 
+  const edges = []
+
+  // 从帖子图谱查找
   const postGraph = store.currentPostGraph
-  if (!postGraph?.edges) return []
+  if (postGraph?.edges) {
+    for (const e of Object.values(postGraph.edges)) {
+      if (e.source === nodeId) edges.push(e)
+    }
+  }
 
-  return Object.values(postGraph.edges)
-    .filter(e => e.source === nodeId)
-    .sort((a, b) => (b.score || 0) - (a.score || 0))
+  // 从人设图谱查找(如果是人设节点)
+  if (nodeId.startsWith('人设:')) {
+    const personaEdges = store.graphData?.edges
+    if (personaEdges) {
+      for (const e of Object.values(personaEdges)) {
+        if (e.source === nodeId) edges.push(e)
+      }
+    }
+  }
+
+  return edges.sort((a, b) => (b.score || 0) - (a.score || 0))
 })
 
 // 获取节点名称(根据节点ID)
@@ -819,23 +854,33 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
 
   if (matchEdges.length === 0) return
 
-  // 收集匹配的人设节点(去重)
+  // 收集匹配的人设节点(去重),获取完整节点数据
   const matchedPersonaMap = new Map()
   for (const edge of matchEdges) {
     if (!matchedPersonaMap.has(edge.target)) {
-      // 从人设节点ID提取信息: "人设:目的点:标签:进行产品种草"
-      const parts = edge.target.split(':')
-      const name = parts[parts.length - 1]
-      const dimension = parts[1] // 灵感点/目的点/关键点
-      const type = parts[2] // 标签/分类/点
-      matchedPersonaMap.set(edge.target, {
-        id: edge.target,
-        name: name,
-        dimension: dimension,
-        type: type,
-        domain: '人设', // 人设节点:实心
-        sourceEdges: [] // 连接的帖子节点
-      })
+      // 从 store 获取完整的人设节点数据
+      const fullNode = store.getNode(edge.target)
+      if (fullNode) {
+        matchedPersonaMap.set(edge.target, {
+          ...fullNode,
+          id: edge.target,
+          sourceEdges: [] // 连接的帖子节点
+        })
+      } else {
+        // 回退:从节点ID提取基本信息
+        const parts = edge.target.split(':')
+        const name = parts[parts.length - 1]
+        const dimension = parts[1]
+        const type = parts[2]
+        matchedPersonaMap.set(edge.target, {
+          id: edge.target,
+          name: name,
+          dimension: dimension,
+          type: type,
+          domain: '人设',
+          sourceEdges: []
+        })
+      }
     }
     matchedPersonaMap.get(edge.target).sourceEdges.push({
       sourceId: edge.source,
@@ -1402,6 +1447,69 @@ function handleLockClick() {
   }
 }
 
+// 基于当前高亮的边集合计算从 fromId 到 toId 的路径
+function computePathInHighlightedEdges(fromId, toId) {
+  // 使用当前高亮的边集合
+  const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
+  if (edgeSet.size === 0) return { nodes: new Set(), edges: new Set() }
+
+  // hover 节点必须在高亮节点中
+  if (!store.highlightedNodeIds.has(fromId)) return { nodes: new Set(), edges: new Set() }
+
+  // 构建邻接表(只包含高亮的边)
+  const adj = new Map()
+  for (const edgeKey of edgeSet) {
+    const [srcId, tgtId] = edgeKey.split('->')
+    if (!srcId || !tgtId) continue
+
+    if (!adj.has(srcId)) adj.set(srcId, [])
+    if (!adj.has(tgtId)) adj.set(tgtId, [])
+    adj.get(srcId).push({ neighbor: tgtId, edgeKey })
+    adj.get(tgtId).push({ neighbor: srcId, edgeKey: `${tgtId}->${srcId}` })
+  }
+
+  // BFS 找路径
+  const visited = new Set([fromId])
+  const parent = new Map()
+  const parentEdge = new Map()
+  const queue = [fromId]
+
+  while (queue.length > 0) {
+    const curr = queue.shift()
+    if (curr === toId) break
+
+    for (const { neighbor, edgeKey } of (adj.get(curr) || [])) {
+      if (!visited.has(neighbor)) {
+        visited.add(neighbor)
+        parent.set(neighbor, curr)
+        parentEdge.set(neighbor, edgeKey)
+        queue.push(neighbor)
+      }
+    }
+  }
+
+  // 回溯路径
+  const pathNodes = new Set()
+  const pathEdges = new Set()
+
+  if (visited.has(toId)) {
+    let curr = toId
+    while (curr) {
+      pathNodes.add(curr)
+      const edgeKey = parentEdge.get(curr)
+      if (edgeKey) {
+        pathEdges.add(edgeKey)
+        // 添加反向边
+        const [src, tgt] = edgeKey.split('->')
+        pathEdges.add(`${tgt}->${src}`)
+      }
+      curr = parent.get(curr)
+    }
+  }
+
+  return { nodes: pathNodes, edges: pathEdges }
+}
+
 // 设置 hover 处理器(在所有元素创建后调用)
 function setupHoverHandlers() {
   if (!svgRef.value || !store.selectedNodeId) return
@@ -1418,18 +1526,28 @@ function setupHoverHandlers() {
     }
   }
 
-  // 添加 hover 处理器(路径计算由 store 统一处理
+  // 添加 hover 处理器(基于当前高亮的边集合计算路径
   allNodes
     .on('mouseenter', function(event, d) {
       const nodeId = d.data?.id || d.id
       // 排除起始节点
       if (nodeId === startNodeId) return
 
-      store.computeHoverPath(startNodeId, nodeId, 'post-tree')
-      store.setHoverNode(d)  // 设置 hover 节点数据用于详情显示
+      // 基于当前高亮的边集合计算路径
+      const { nodes: pathNodes, edges: pathEdges } = computePathInHighlightedEdges(nodeId, startNodeId)
+
+      // 如果找不到路径,不做任何处理
+      if (pathNodes.size === 0) return
+
+      // 更新 store 状态
+      store.hoverNodeId = nodeId
+      store.hoverPathNodes = pathNodes
+      store.hoverPathEdges = pathEdges
+      store.hoverSource = 'post-tree'
+      store.setHoverNode(d)
 
       // 显示锁定按钮(在当前hover节点上)
-      if (store.hoverPathNodes.size > 0) {
+      if (pathNodes.size > 0) {
         // 如果已锁定,按钮显示在锁定节点上
         if (store.lockedHoverNodeId) {
           const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
@@ -1446,14 +1564,8 @@ function setupHoverHandlers() {
       // 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
       store.clearHover()
 
+      // 只管理锁定按钮的显示,高亮由 watch 统一处理
       if (store.lockedHoverNodeId) {
-        // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
-        const svg = d3.select(svgRef.value)
-        const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
-        const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
-        const allLabels = svg.selectAll('.match-score, .walked-score')
-        // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
-        applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, null)
         // 在锁定节点上显示解锁按钮
         const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
         if (lockedNodeInfo?.element) {
@@ -1705,6 +1817,59 @@ watch(() => store.focusEdgeEndpoints, (endpoints) => {
 
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
+// 应用基于边集合的 hover 高亮
+function applyPathHighlightWithEdges(pathNodes, pathEdges) {
+  if (!svgRef.value) return
+
+  const svg = d3.select(svgRef.value)
+  const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
+  const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
+  const allLabels = svg.selectAll('.match-score, .walked-score')
+
+  // 先清除所有残留的高亮类(但保留 selected 类给选中节点)
+  allNodes
+    .classed('highlighted', false)
+    .classed('locked-path', false)
+  allLinks
+    .classed('highlighted', false)
+    .classed('locked-path', false)
+  allLabels
+    .classed('highlighted', false)
+    .classed('locked-path', false)
+
+  // 节点高亮(保持选中节点的 selected 样式)
+  const activeNodeId = store.selectedNodeId
+  allNodes
+    .classed('dimmed', function(d) {
+      const nodeId = d.data?.id || d.id
+      return !pathNodes.has(nodeId)
+    })
+    .classed('selected', function(d) {
+      const nodeId = d.data?.id || d.id
+      return nodeId === activeNodeId
+    })
+
+  // 边高亮(基于边集合判断)
+  allLinks.each(function(d) {
+    const srcId = d.source?.data?.id || d.source?.id || d.source
+    const tgtId = d.target?.data?.id || d.target?.id || d.target
+    const edgeKey = `${srcId}->${tgtId}`
+    const edgeKeyRev = `${tgtId}->${srcId}`
+    const inPath = pathEdges.has(edgeKey) || pathEdges.has(edgeKeyRev)
+    d3.select(this).classed('dimmed', !inPath)
+  })
+
+  // 标签高亮
+  allLabels.each(function(d) {
+    const srcId = d.source?.data?.id || d.source?.id || d.source
+    const tgtId = d.target?.data?.id || d.target?.id || d.target
+    const edgeKey = `${srcId}->${tgtId}`
+    const edgeKeyRev = `${tgtId}->${srcId}`
+    const inPath = pathEdges.has(edgeKey) || pathEdges.has(edgeKeyRev)
+    d3.select(this).classed('dimmed', !inPath)
+  })
+}
+
 // 监听 hover 状态变化(用于左右联动)
 watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   if (!svgRef.value) return
@@ -1712,16 +1877,23 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   // 边 hover 时不处理,避免覆盖边 hover 高亮
   if (isEdgeHovering) return
 
-  const svg = d3.select(svgRef.value)
+  // hover 结束时,无论来源是什么,都恢复原有高亮
+  if (store.hoverPathNodes.size === 0) {
+    updateHighlight()
+    if (!store.lockedHoverNodeId) {
+      hideLockButton()
+    }
+    return
+  }
 
-  const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
-  const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
-  const allLabels = svg.selectAll('.match-score, .walked-score')
+  // 推导图谱的 hover 不影响待解构图的高亮(保持自己的激活状态)
+  if (store.hoverSource === 'derivation') {
+    return
+  }
 
-  if (store.hoverPathNodes.size > 0) {
-    // 应用 hover 高亮(支持嵌套:传入锁定路径)
-    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
-    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
+  if (store.hoverPathNodes.size > 0 && store.hoverPathEdges.size > 0) {
+    // 使用边集合来精确判断哪些边应该高亮
+    applyPathHighlightWithEdges(store.hoverPathNodes, store.hoverPathEdges)
 
     // 如果是从 GraphView 触发的,缩放到显示完整路径
     if (store.hoverSource === 'graph') {
@@ -1735,12 +1907,20 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
         showLockButton(nodeInfo.element)
       }
     }
-  } else {
-    // 清除 hover,恢复原有高亮
-    updateHighlight()
-    // 如果没有锁定,隐藏按钮
-    if (!store.lockedHoverNodeId) {
-      hideLockButton()
+  } else if (store.hoverPathNodes.size > 0) {
+    // 兼容旧逻辑(没有边集合时)
+    const svg = d3.select(svgRef.value)
+    const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
+    const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
+    const allLabels = svg.selectAll('.match-score, .walked-score')
+    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath, store.selectedNodeId)
+
+    if (store.hoverNodeId) {
+      const nodeInfo = nodeElements[store.hoverNodeId]
+      if (nodeInfo?.element) {
+        showLockButton(nodeInfo.element)
+      }
     }
   }
 })
@@ -1812,10 +1992,10 @@ function restoreLockedHover() {
   const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
   const allLabels = svg.selectAll('.match-score, .walked-score')
 
-  // 恢复高亮效果(传入锁定路径)
+  // 恢复高亮效果(传入锁定路径和选中节点
   if (store.hoverPathNodes.size > 0) {
     const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
-    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
+    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath, store.selectedNodeId)
   }
 
   // 恢复锁定按钮

+ 2 - 2
script/visualization/src/components/TreeView.vue

@@ -407,8 +407,8 @@ watch(() => store.hoverNodeId, (hoverNodeId) => {
       treeNode.children.forEach(child => neighborNodes.add(child.data.id))
     }
 
-    // 应用高亮(只高亮上下游节点和连接它们的边)
-    applyHoverHighlight(allNodes, allLinks, null, neighborNodes)
+    // 应用高亮(只高亮上下游节点和连接它们的边,保持选中节点样式
+    applyHoverHighlight(allNodes, allLinks, null, neighborNodes, null, store.selectedNodeId)
 
     // 缩放到所有上下游节点都可见
     zoomToNodes(neighborNodes)

+ 57 - 0
script/visualization/src/stores/graph.js

@@ -560,6 +560,55 @@ export const useGraphStore = defineStore('graph', () => {
   // 需要聚焦的边端点(source, target)
   const focusEdgeEndpoints = ref(null)
 
+  // ==================== 各图独立的高亮状态(用于相关图合并) ====================
+  // 推导图谱的高亮路径
+  const derivationHighlight = ref({ nodes: new Set(), edges: [] })
+  // 待解构图的高亮路径(复用 postWalkedNodes/postWalkedEdges)
+  // 人设图的高亮路径(复用 walkedEdges 和 highlightedNodeIds)
+
+  // 设置推导图谱高亮
+  function setDerivationHighlight(nodes, edges) {
+    derivationHighlight.value = { nodes: new Set(nodes), edges: [...edges] }
+  }
+
+  // 清除推导图谱高亮
+  function clearDerivationHighlight() {
+    derivationHighlight.value = { nodes: new Set(), edges: [] }
+  }
+
+  // 合并后的高亮节点(相关图使用)
+  const mergedHighlightNodes = computed(() => {
+    const merged = new Set()
+    // 1. 人设游走的节点
+    for (const id of highlightedNodeIds.value) merged.add(id)
+    // 2. 帖子游走的节点
+    for (const node of postWalkedNodes.value) merged.add(node.id || node)
+    // 3. 推导图谱的节点
+    for (const id of derivationHighlight.value.nodes) merged.add(id)
+    return merged
+  })
+
+  // 合并后的高亮边(相关图使用)
+  const mergedHighlightEdges = computed(() => {
+    const merged = []
+    const edgeSet = new Set()
+    // 辅助函数:添加边并去重
+    const addEdge = (edge) => {
+      const key = `${edge.source}->${edge.target}`
+      if (!edgeSet.has(key)) {
+        merged.push(edge)
+        edgeSet.add(key)
+      }
+    }
+    // 1. 人设游走的边
+    for (const edge of walkedEdges.value) addEdge(edge)
+    // 2. 帖子游走的边
+    for (const edge of postWalkedEdges.value) addEdge(edge)
+    // 3. 推导图谱的边
+    for (const edge of derivationHighlight.value.edges) addEdge(edge)
+    return merged
+  })
+
   // 获取节点
   function getNode(nodeId) {
     return graphData.value.nodes[nodeId] || currentPostGraph.value?.nodes?.[nodeId]
@@ -715,6 +764,8 @@ export const useGraphStore = defineStore('graph', () => {
     focusNodeId.value = null
     focusEdgeEndpoints.value = null
     clearHover()
+    // 清除推导图谱高亮
+    clearDerivationHighlight()
   }
 
   // ==================== Hover 状态(左右联动) ====================
@@ -1147,6 +1198,12 @@ export const useGraphStore = defineStore('graph', () => {
     selectNode,
     selectEdge,
     clearSelection,
+    // 各图独立高亮(用于相关图合并)
+    derivationHighlight,
+    setDerivationHighlight,
+    clearDerivationHighlight,
+    mergedHighlightNodes,
+    mergedHighlightEdges,
     // Hover 联动
     hoverNodeId,
     hoverPathNodes,

+ 10 - 4
script/visualization/src/utils/highlight.js

@@ -80,8 +80,9 @@ export function findPath(startId, endId, links) {
  * @param {D3Selection} labelSelection - 标签选择集(可选)
  * @param {Set} pathNodes - 当前 hover 路径上的节点ID集合
  * @param {Set} lockedPathNodes - 锁定路径上的节点ID集合(可选)
+ * @param {string} selectedNodeId - 选中节点ID(保持选中样式)
  */
-export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection, pathNodes, lockedPathNodes = null) {
+export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection, pathNodes, lockedPathNodes = null, selectedNodeId = null) {
   // 合并路径:锁定 + hover
   const allPathNodes = new Set([...pathNodes])
   if (lockedPathNodes) {
@@ -90,13 +91,14 @@ export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection
     }
   }
 
-  // 节点:不在任何路径中的置灰,只在锁定路径中的半透明
+  // 节点:不在任何路径中的置灰,只在锁定路径中的半透明,保持选中节点样式
   nodeSelection
     .classed('dimmed', d => !allPathNodes.has(getNodeId(d)))
     .classed('locked-path', d => {
       const id = getNodeId(d)
       return lockedPathNodes && lockedPathNodes.has(id) && !pathNodes.has(id)
     })
+    .classed('selected', d => selectedNodeId && getNodeId(d) === selectedNodeId)
 
   // 边:不在任何路径中的置灰,只在锁定路径中的半透明
   linkSelection
@@ -162,8 +164,10 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
   const svg = d3.select(svgEl)
   const hasHighlight = highlightedIds.size > 0
 
-  // 所有节点:在 highlightedIds 中的保持,否则置灰
+  // 所有节点:先清除残留的 hover 相关类,然后应用高亮
   svg.selectAll('.tree-node, .match-node, .graph-node, .walked-node')
+    .classed('highlighted', false)
+    .classed('locked-path', false)
     .classed('selected', d => {
       const nodeId = getNodeId(d)
       if (selectedId) {
@@ -175,8 +179,10 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
     })
     .classed('dimmed', d => hasHighlight && !highlightedIds.has(getNodeId(d)))
 
-  // 所有边:在 walkedEdgeSet 中的保持,否则置灰
+  // 所有边:先清除残留的 hover 相关类,然后应用高亮
   svg.selectAll('.tree-link, .graph-link, .graph-link-label, .match-link, .match-score, .walked-link, .walked-score')
+    .classed('highlighted', false)
+    .classed('locked-path', false)
     .classed('dimmed', function(d) {
       if (!hasHighlight) return false
       if (d && walkedEdgeSet) {