Преглед на файлове

feat: 添加推导图谱可视化组件

- 新增 DerivationView.vue 组件,展示推导边和组成边
- 支持点击激活节点,hover 显示到激活节点的路径
- 路径高亮基于边判断,而非节点(避免高亮无关边)
- hover 路径包含至少一个推导边
- 箭头颜色与边类型一致(推导青色、组成浅绿色)
- 支持与 PostTreeView 的点击联动
- 本地维护激活状态,不受其他视图 hover 影响

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui преди 1 ден
родител
ревизия
5532a1a851

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

@@ -108,6 +108,13 @@
       >
         <PostTreeView class="flex-1" :show-expand="true" />
       </div>
+      <!-- 右侧:推导图谱 -->
+      <div
+        class="shrink-0 bg-base-200 border-l border-base-300 transition-all duration-200"
+        :class="getDerivationPanelClass()"
+      >
+        <DerivationView class="h-full" />
+      </div>
     </main>
   </div>
 </template>
@@ -118,6 +125,7 @@ import TreeView from './components/TreeView.vue'
 import GraphView from './components/GraphView.vue'
 import PostTreeView from './components/PostTreeView.vue'
 import DetailPanel from './components/DetailPanel.vue'
+import DerivationView from './components/DerivationView.vue'
 import { useGraphStore } from './stores/graph'
 import { edgeTypeColors } from './config/edgeStyle'
 
@@ -166,7 +174,14 @@ function getGraphClass() {
 function getPostTreeClass() {
   const panel = store.expandedPanel
   if (panel === 'post-tree') return 'flex-1'
-  if (panel === 'persona-tree' || panel === 'graph') return 'w-0 opacity-0 overflow-hidden'
+  if (panel === 'persona-tree' || panel === 'graph' || panel === 'derivation') return 'w-0 opacity-0 overflow-hidden'
   return 'flex-1'
 }
+
+function getDerivationPanelClass() {
+  const panel = store.expandedPanel
+  if (panel === 'derivation') return 'flex-1'
+  if (panel === 'persona-tree' || panel === 'graph' || panel === 'post-tree') return 'w-0 opacity-0 overflow-hidden'
+  return 'w-[400px]'
+}
 </script>

+ 713 - 0
script/visualization/src/components/DerivationView.vue

@@ -0,0 +1,713 @@
+<template>
+  <div class="flex flex-col h-full">
+    <!-- 头部 -->
+    <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60 shrink-0">
+      <span>推导图谱</span>
+      <div class="flex items-center gap-2">
+        <span v-if="derivationStats.edges > 0" class="text-primary">
+          {{ derivationStats.edges }} 条推导边
+        </span>
+        <span v-else class="text-base-content/40">暂无推导数据</span>
+        <!-- 放大/恢复按钮 -->
+        <button
+          v-if="store.expandedPanel !== 'derivation'"
+          @click="store.expandPanel('derivation')"
+          class="btn btn-ghost btn-xs"
+          title="放大"
+        >⤢</button>
+        <button
+          v-if="store.expandedPanel !== 'default'"
+          @click="store.resetLayout()"
+          class="btn btn-ghost btn-xs"
+          title="恢复"
+        >⊡</button>
+      </div>
+    </div>
+
+    <!-- SVG 容器 -->
+    <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
+      <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import * as d3 from 'd3'
+import { useGraphStore } from '../stores/graph'
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
+import { getEdgeStyle } from '../config/edgeStyle'
+import { applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
+
+const store = useGraphStore()
+
+const containerRef = ref(null)
+const svgRef = ref(null)
+
+let simulation = null
+let mainG = null
+let currentZoom = null
+let nodesData = []
+let linksData = []
+
+// D3 选择集(用于联动)
+let nodeSelection = null
+let linkSelection = null
+let linkLabelSelection = null
+
+// 选中的节点(激活状态)
+const selectedNodeId = ref(null)
+// 选中节点的路径(本地状态,不受其他视图 hover 影响)
+const selectedPathNodes = ref(new Set())
+const selectedPathEdges = ref(new Set())
+
+// 计算推导统计
+const derivationStats = computed(() => {
+  const postGraph = store.currentPostGraph
+  if (!postGraph || !postGraph.edges) return { edges: 0, nodes: 0 }
+
+  let edgeCount = 0
+  for (const edge of Object.values(postGraph.edges)) {
+    if (edge.type === '推导' || edge.type === '组成') {
+      edgeCount++
+    }
+  }
+  return { edges: edgeCount }
+})
+
+// 提取推导图谱数据
+function extractDerivationData() {
+  const postGraph = store.currentPostGraph
+  if (!postGraph) return { nodes: [], links: [] }
+
+  const nodesMap = new Map()
+  const links = []
+
+  for (const edge of Object.values(postGraph.edges)) {
+    if (edge.type !== '推导' && edge.type !== '组成') continue
+
+    if (!nodesMap.has(edge.source)) {
+      const nodeData = postGraph.nodes[edge.source]
+      if (nodeData) {
+        nodesMap.set(edge.source, {
+          id: edge.source,
+          name: nodeData.name,
+          dimension: nodeData.dimension,
+          type: nodeData.type,
+          domain: nodeData.domain || '帖子',
+          ...nodeData
+        })
+      }
+    }
+
+    if (!nodesMap.has(edge.target)) {
+      const nodeData = postGraph.nodes[edge.target]
+      if (nodeData) {
+        nodesMap.set(edge.target, {
+          id: edge.target,
+          name: nodeData.name,
+          dimension: nodeData.dimension,
+          type: nodeData.type,
+          domain: nodeData.domain || '帖子',
+          ...nodeData
+        })
+      }
+    }
+
+    links.push({
+      source: edge.source,
+      target: edge.target,
+      type: edge.type,
+      score: edge.score,
+      detail: edge.detail
+    })
+  }
+
+  return {
+    nodes: Array.from(nodesMap.values()),
+    links
+  }
+}
+
+// 显示锁定按钮
+function showLockButton(nodeEl, isLocked = false) {
+  if (!nodeEl) return
+
+  const node = d3.select(nodeEl)
+  const textEl = node.select('text')
+  if (textEl.empty()) return
+
+  const nodeData = node.datum()
+  const currentNodeId = nodeData?.id
+  const isThisNodeLocked = store.lockedHoverNodeId === currentNodeId
+
+  let btn = textEl.select('.lock-btn')
+  if (!btn.empty()) {
+    btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
+       .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
+    if (!isThisNodeLocked) startBreathingAnimation(btn)
+    return
+  }
+
+  // 清除其他按钮
+  if (svgRef.value) {
+    d3.select(svgRef.value).selectAll('.lock-btn').remove()
+  }
+
+  const newBtn = textEl.append('tspan')
+    .attr('class', 'lock-btn')
+    .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
+    .attr('font-weight', 'bold')
+    .style('cursor', 'pointer')
+    .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
+    .on('click', (e) => {
+      e.stopPropagation()
+      handleLockClick()
+    })
+
+  if (!isThisNodeLocked) {
+    startBreathingAnimation(newBtn)
+  }
+}
+
+// 呼吸动画
+function startBreathingAnimation(btn) {
+  function breathe() {
+    if (btn.empty() || !btn.node()) return
+    if (store.lockedHoverNodeId) return
+    btn
+      .transition()
+      .duration(800)
+      .attr('fill', '#90cdf4')
+      .transition()
+      .duration(800)
+      .attr('fill', '#63b3ed')
+      .on('end', breathe)
+  }
+  breathe()
+}
+
+// 隐藏锁定按钮
+function hideLockButton() {
+  if (svgRef.value) {
+    d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
+  }
+}
+
+// 抖动按钮
+function shakeLockButton() {
+  d3.selectAll('.lock-btn')
+    .interrupt()
+    .attr('fill', '#fc8181')
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 0)
+    .transition().duration(200).attr('fill', '#f6ad55')
+}
+
+// 处理锁定按钮点击
+function handleLockClick() {
+  const currentHoverNodeId = store.hoverNodeId
+
+  if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
+    // 解锁
+    store.clearLockedHover()
+    if (store.lockedHoverNodeId) {
+      d3.selectAll('.lock-btn')
+        .interrupt()
+        .text(' 🔓解锁')
+        .attr('fill', '#f6ad55')
+    } else {
+      d3.selectAll('.lock-btn').interrupt().remove()
+    }
+  } else if (currentHoverNodeId) {
+    // 锁定
+    store.lockCurrentHover()
+    d3.selectAll('.lock-btn')
+      .interrupt()
+      .text(' 🔓解锁')
+      .attr('fill', '#f6ad55')
+  }
+}
+
+// hover 节点处理(使用 store 统一机制)
+function handleNodeHover(event, d) {
+  // 只有在有选中节点时才触发路径高亮
+  if (!selectedNodeId.value) return
+
+  // 不处理选中节点自身
+  if (d.id === selectedNodeId.value) return
+
+  // 计算从当前 hover 节点到激活节点的路径
+  store.computeDerivationPathTo(d.id, selectedNodeId.value, 'derivation')
+
+  // 显示锁定按钮
+  if (store.hoverPathNodes.size > 0) {
+    if (store.lockedHoverNodeId) {
+      // 已锁定,在锁定节点上显示按钮
+      nodeSelection.each(function(nd) {
+        if (nd.id === store.lockedHoverNodeId) {
+          showLockButton(this, true)
+        }
+      })
+    } else {
+      showLockButton(this)
+    }
+  }
+}
+
+// hover 离开处理
+function handleNodeHoverOut() {
+  // 只有在有选中节点时才处理
+  if (!selectedNodeId.value) return
+
+  store.clearHover()
+
+  // 恢复到选中节点的路径高亮(而不是清除所有高亮)
+  if (selectedNodeId.value && !store.lockedHoverNodeId) {
+    store.computeDerivationHoverPath(selectedNodeId.value, 'derivation')
+  }
+
+  if (store.lockedHoverNodeId) {
+    // 恢复锁定状态,在锁定节点上显示按钮
+    nodeSelection.each(function(d) {
+      if (d.id === store.lockedHoverNodeId) {
+        showLockButton(this, true)
+      }
+    })
+  } else {
+    hideLockButton()
+  }
+}
+
+// 点击节点处理(联动 store)
+function handleNodeClick(event, d) {
+  event.stopPropagation()
+
+  // 锁定状态下点击节点提醒
+  if (store.lockedHoverNodeId) {
+    shakeLockButton()
+    return
+  }
+
+  // 设置/切换选中状态
+  if (selectedNodeId.value === d.id) {
+    // 再次点击取消选中
+    selectedNodeId.value = null
+    clearHighlightState()
+  } else {
+    // 选中新节点
+    selectedNodeId.value = d.id
+    applySelectedHighlight()
+  }
+
+  // 联动 store
+  store.selectNode(d.id)
+}
+
+// 应用选中状态的高亮(只高亮选中节点的入边路径)
+function applySelectedHighlight() {
+  if (!selectedNodeId.value || !nodeSelection || !linkSelection) return
+
+  // 计算选中节点的入边路径并存储到本地
+  store.computeDerivationHoverPath(selectedNodeId.value, 'derivation')
+  selectedPathNodes.value = new Set(store.hoverPathNodes)
+  selectedPathEdges.value = new Set(store.hoverPathEdges)
+  applyDerivationHighlight()
+}
+
+// 清除高亮状态
+function clearHighlightState() {
+  selectedPathNodes.value = new Set()
+  selectedPathEdges.value = new Set()
+  store.clearHover()
+  store.clearAllLocked()
+  if (nodeSelection && linkSelection) {
+    clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
+    // 恢复箭头(根据边类型)
+    linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
+  }
+  hideLockButton()
+}
+
+// 点击空白处
+function handleSvgClick(event) {
+  // 锁定状态下点击空白无效
+  if (store.lockedHoverNodeId) return
+
+  if (event.target === svgRef.value) {
+    selectedNodeId.value = null
+    clearHighlightState()
+    store.clearSelection()
+  }
+}
+
+// 应用 hover 高亮效果(基于推导图谱的边)
+function applyDerivationHighlight() {
+  if (!nodeSelection || !linkSelection) return
+
+  // 优先使用 store 的 hover 路径(推导图谱内部 hover),否则使用本地状态
+  const storePathNodes = store.hoverSource === 'derivation' ? store.hoverPathNodes : new Set()
+  const storePathEdges = store.hoverSource === 'derivation' ? store.hoverPathEdges : new Set()
+  const pathNodes = storePathNodes.size > 0 ? storePathNodes : selectedPathNodes.value
+  const pathEdges = storePathEdges.size > 0 ? storePathEdges : selectedPathEdges.value
+  const lockedPathNodes = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+  const hasHighlight = pathNodes.size > 0
+
+  if (!hasHighlight) {
+    clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
+    // 恢复箭头(根据边类型)
+    linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
+    return
+  }
+
+  // 合并路径节点
+  const allPathNodes = new Set([...pathNodes])
+  if (lockedPathNodes) {
+    for (const id of lockedPathNodes) {
+      allPathNodes.add(id)
+    }
+  }
+
+  // 节点高亮
+  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)
+
+  // 边高亮(使用 pathEdges 判断,而不是节点)
+  linkSelection.each(function(d) {
+    const srcId = typeof d.source === 'object' ? d.source.id : d.source
+    const tgtId = typeof d.target === 'object' ? d.target.id : d.target
+    const edgeKey = `${srcId}->${tgtId}`
+
+    const inPath = pathEdges.has(edgeKey)
+    // TODO: 如果需要支持锁定路径的边,也需要存储 lockedPathEdges
+    const isLockedPath = false
+
+    d3.select(this)
+      .classed('dimmed', !inPath)
+      .classed('locked-path', isLockedPath)
+      .classed('highlighted', inPath)
+
+    // 切换箭头(根据边类型选择对应颜色)
+    const edgeType = d.type
+    let markerUrl = `url(#arrow-${edgeType})`
+    if (!inPath) {
+      markerUrl = `url(#arrow-${edgeType}-dimmed)`
+    } else if (isLockedPath) {
+      markerUrl = `url(#arrow-${edgeType}-locked)`
+    }
+    d3.select(this).attr('marker-end', markerUrl)
+  })
+
+  // 标签高亮
+  if (linkLabelSelection) {
+    linkLabelSelection.each(function(d) {
+      const srcId = typeof d.source === 'object' ? d.source.id : d.source
+      const tgtId = typeof d.target === 'object' ? d.target.id : d.target
+      const edgeKey = `${srcId}->${tgtId}`
+
+      const inPath = pathEdges.has(edgeKey)
+
+      d3.select(this)
+        .classed('dimmed', !inPath)
+        .classed('locked-path', false)
+    })
+  }
+}
+
+// 渲染力导向图
+function render() {
+  if (!svgRef.value || !containerRef.value) return
+
+  const svg = d3.select(svgRef.value)
+  svg.selectAll('*').remove()
+
+  const { nodes, links } = extractDerivationData()
+  nodesData = nodes
+  linksData = links
+
+  if (nodes.length === 0) return
+
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+
+  // 定义箭头标记(为每种边类型创建对应颜色的箭头)
+  const defs = svg.append('defs')
+  const arrowColors = {
+    '推导': '#00bcd4',
+    '组成': '#8bc34a'
+  }
+
+  // 为每种边类型创建正常、置灰、锁定三种状态的箭头
+  for (const [type, color] of Object.entries(arrowColors)) {
+    // 正常箭头
+    defs.append('marker')
+      .attr('id', `arrow-${type}`)
+      .attr('viewBox', '0 -2 4 4')
+      .attr('refX', 10)
+      .attr('refY', 0)
+      .attr('markerWidth', 3)
+      .attr('markerHeight', 3)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-2L4,0L0,2')
+      .attr('fill', color)
+
+    // 置灰箭头
+    defs.append('marker')
+      .attr('id', `arrow-${type}-dimmed`)
+      .attr('viewBox', '0 -2 4 4')
+      .attr('refX', 10)
+      .attr('refY', 0)
+      .attr('markerWidth', 3)
+      .attr('markerHeight', 3)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-2L4,0L0,2')
+      .attr('fill', color)
+      .attr('fill-opacity', 0.1)
+
+    // 锁定路径箭头
+    defs.append('marker')
+      .attr('id', `arrow-${type}-locked`)
+      .attr('viewBox', '0 -2 4 4')
+      .attr('refX', 10)
+      .attr('refY', 0)
+      .attr('markerWidth', 3)
+      .attr('markerHeight', 3)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-2L4,0L0,2')
+      .attr('fill', color)
+      .attr('fill-opacity', 0.4)
+  }
+
+  mainG = svg.append('g')
+
+  currentZoom = d3.zoom()
+    .scaleExtent([0.1, 4])
+    .on('zoom', (event) => {
+      mainG.attr('transform', event.transform)
+    })
+
+  svg.call(currentZoom)
+
+  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(30))
+
+  // 绘制边
+  linkSelection = mainG.append('g')
+    .attr('class', 'links')
+    .selectAll('line')
+    .data(links)
+    .join('line')
+    .attr('class', 'graph-link')
+    .attr('stroke', d => getEdgeStyle(d).color)
+    .attr('stroke-opacity', d => getEdgeStyle(d).opacity)
+    .attr('stroke-width', d => getEdgeStyle(d).strokeWidth)
+    .attr('stroke-dasharray', d => getEdgeStyle(d).strokeDasharray)
+    .attr('marker-end', d => `url(#arrow-${d.type})`)
+    .style('cursor', 'pointer')
+    .on('click', (e, d) => {
+      e.stopPropagation()
+      store.selectEdge(d)
+    })
+
+  // 边标签
+  linkLabelSelection = mainG.append('g')
+    .attr('class', 'link-labels')
+    .selectAll('g')
+    .data(links.filter(d => d.score > 0))
+    .join('g')
+    .attr('class', 'graph-link-label')
+
+  linkLabelSelection.append('rect')
+    .attr('x', -14)
+    .attr('y', -6)
+    .attr('width', 28)
+    .attr('height', 12)
+    .attr('rx', 2)
+    .attr('fill', '#1d232a')
+    .attr('opacity', 0.9)
+
+  linkLabelSelection.append('text')
+    .attr('text-anchor', 'middle')
+    .attr('dy', '0.35em')
+    .attr('fill', d => getEdgeStyle(d).color)
+    .attr('font-size', '8px')
+    .text(d => d.score.toFixed(2))
+
+  // 绘制节点
+  nodeSelection = mainG.append('g')
+    .attr('class', 'nodes')
+    .selectAll('g')
+    .data(nodes)
+    .join('g')
+    .attr('class', 'graph-node')
+    .style('cursor', 'pointer')
+    .on('mouseenter', handleNodeHover)
+    .on('mouseleave', handleNodeHoverOut)
+    .on('click', handleNodeClick)
+    .call(d3.drag()
+      .on('start', dragstarted)
+      .on('drag', dragged)
+      .on('end', dragended))
+
+  // 节点形状
+  nodeSelection.each(function(d) {
+    const style = getNodeStyle(d)
+    applyNodeShape(d3.select(this), style)
+  })
+
+  // 节点标签
+  nodeSelection.append('text')
+    .attr('dy', 20)
+    .attr('text-anchor', 'middle')
+    .text(d => {
+      const name = d.name || ''
+      return name.length > 8 ? name.slice(0, 8) + '…' : name
+    })
+
+  // 更新位置
+  simulation.on('tick', () => {
+    linkSelection
+      .attr('x1', d => d.source.x)
+      .attr('y1', d => d.source.y)
+      .attr('x2', d => d.target.x)
+      .attr('y2', d => d.target.y)
+
+    linkLabelSelection.attr('transform', d => {
+      const x = (d.source.x + d.target.x) / 2
+      const y = (d.source.y + d.target.y) / 2 - 10
+      return `translate(${x},${y})`
+    })
+
+    nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
+  })
+
+  nextTick(() => {
+    setTimeout(() => fitToView(), 500)
+  })
+}
+
+// 适应视图
+function fitToView() {
+  if (!mainG || !svgRef.value || !containerRef.value) return
+
+  const svg = d3.select(svgRef.value)
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+
+  try {
+    const bounds = mainG.node().getBBox()
+    if (bounds.width === 0 || bounds.height === 0) return
+
+    const scale = Math.min(
+      width / (bounds.width + 80),
+      height / (bounds.height + 80),
+      1.5
+    )
+    const tx = (width - bounds.width * scale) / 2 - bounds.x * scale
+    const ty = (height - bounds.height * scale) / 2 - bounds.y * scale
+    svg.call(currentZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale))
+  } catch (e) {
+    // getBBox 可能在元素不可见时失败
+  }
+}
+
+// 拖拽函数
+function dragstarted(event, d) {
+  if (!event.active) simulation.alphaTarget(0.3).restart()
+  d.fx = d.x
+  d.fy = d.y
+}
+
+function dragged(event, d) {
+  d.fx = event.x
+  d.fy = event.y
+}
+
+function dragended(event, d) {
+  if (!event.active) simulation.alphaTarget(0)
+  d.fx = null
+  d.fy = null
+}
+
+// 监听帖子切换
+watch(() => store.currentPostGraph, () => {
+  nextTick(() => render())
+}, { immediate: true })
+
+// 监听面板展开状态变化
+watch(() => store.expandedPanel, () => {
+  nextTick(() => {
+    if (containerRef.value) {
+      setTimeout(() => fitToView(), 300)
+    }
+  })
+})
+
+// 监听 hover 状态变化(联动)
+watch([() => store.hoverPathNodes.size, () => store.hoverNodeId, () => store.hoverSource], () => {
+  if (!nodeSelection || !linkSelection) return
+
+  // 只处理来自推导图谱的 hover
+  if (store.hoverSource === 'derivation') {
+    applyDerivationHighlight()
+  } else if (store.hoverSource !== 'derivation' && selectedNodeId.value) {
+    // 其他视图的 hover 不影响推导图谱的激活状态,保持当前激活节点的高亮
+    // 不做任何处理,保持现有高亮
+  } else if (store.hoverPathNodes.size === 0 && !selectedNodeId.value) {
+    // 没有激活节点且 hover 清空时,清除高亮
+    applyDerivationHighlight()
+  }
+})
+
+// 监听外部节点选中(联动:PostTreeView 点击节点时同步激活推导图谱)
+watch(() => store.selectedNodeId, (newId) => {
+  if (!nodeSelection || !linkSelection) return
+
+  // 检查该节点是否在推导图谱中
+  const nodeInGraph = nodesData.find(n => n.id === newId)
+
+  if (nodeInGraph) {
+    // 节点在推导图谱中,激活并显示高亮
+    selectedNodeId.value = newId
+    store.computeDerivationHoverPath(newId, 'derivation')
+    selectedPathNodes.value = new Set(store.hoverPathNodes)
+    selectedPathEdges.value = new Set(store.hoverPathEdges)
+    applyDerivationHighlight()
+  } else {
+    // 节点不在推导图谱中,清除选中状态
+    selectedNodeId.value = null
+    selectedPathNodes.value = new Set()
+    selectedPathEdges.value = new Set()
+    clearHighlightState()
+  }
+})
+
+// 窗口大小变化时适应视图
+let resizeObserver = null
+onMounted(() => {
+  resizeObserver = new ResizeObserver(() => {
+    if (simulation) {
+      fitToView()
+    }
+  })
+  if (containerRef.value) {
+    resizeObserver.observe(containerRef.value)
+  }
+})
+
+onUnmounted(() => {
+  if (simulation) simulation.stop()
+  if (resizeObserver) resizeObserver.disconnect()
+})
+</script>

+ 21 - 3
script/visualization/src/config/edgeStyle.js

@@ -6,7 +6,9 @@ export const edgeTypeColors = {
   '包含': '#3498db',
   '标签共现': '#2ecc71',
   '分类共现': '#f39c12',
-  '匹配': '#e94560'
+  '匹配': '#e94560',
+  '推导': '#00bcd4',  // 青色 - 推导关系
+  '组成': '#8bc34a'   // 浅绿色 - 组合成员
 }
 
 // 获取边样式(统一入口)
@@ -22,11 +24,27 @@ export function getEdgeStyle(edge) {
     strokeDasharray = (score >= 0.8) ? 'none' : '4,2'
   }
 
+  // 推导边:使用箭头,根据分数调整透明度
+  // 组成边:虚线样式
+  if (type === '组成') {
+    strokeDasharray = '3,3'
+  }
+
+  // 不同边类型的透明度计算
+  let opacity = 0.3
+  if (type === '匹配') {
+    opacity = Math.max(0.3, score * 0.7)
+  } else if (type === '推导') {
+    opacity = Math.max(0.4, score * 0.8)
+  } else if (type === '组成') {
+    opacity = 0.6
+  }
+
   return {
     color,
-    strokeWidth: 1.5,
+    strokeWidth: type === '推导' ? 2 : 1.5,
     strokeDasharray,
-    opacity: type === '匹配' ? Math.max(0.3, score * 0.7) : 0.3,
+    opacity,
     scoreText: score !== undefined ? score.toFixed(2) : ''
   }
 }

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

@@ -655,6 +655,7 @@ export const useGraphStore = defineStore('graph', () => {
   // ==================== Hover 状态(左右联动) ====================
   const hoverNodeId = ref(null)  // 当前 hover 的节点 ID
   const hoverPathNodes = ref(new Set())  // hover 路径上的节点集合
+  const hoverPathEdges = ref(new Set())  // hover 路径上的边集合 "source->target"
   const hoverSource = ref(null)  // hover 来源: 'graph' | 'post-tree'
 
   // 锁定栈(支持嵌套锁定)
@@ -752,10 +753,12 @@ export const useGraphStore = defineStore('graph', () => {
       const top = lockedStack.value[lockedStack.value.length - 1]
       hoverNodeId.value = top.nodeId
       hoverPathNodes.value = new Set(top.pathNodes)
+      hoverPathEdges.value = new Set(top.pathEdges || [])
       hoverSource.value = null
     } else {
       hoverNodeId.value = null
       hoverPathNodes.value = new Set()
+      hoverPathEdges.value = new Set()
       hoverSource.value = null
     }
   }
@@ -766,6 +769,7 @@ export const useGraphStore = defineStore('graph', () => {
       lockedStack.value.push({
         nodeId: hoverNodeId.value,
         pathNodes: new Set(hoverPathNodes.value),
+        pathEdges: new Set(hoverPathEdges.value),
         startId: lockedHoverStartId.value || startId  // 继承之前的起点
       })
     }
@@ -781,6 +785,7 @@ export const useGraphStore = defineStore('graph', () => {
       // 栈空,完全清除
       hoverNodeId.value = null
       hoverPathNodes.value = new Set()
+      hoverPathEdges.value = new Set()
       hoverSource.value = null
     }
   }
@@ -790,9 +795,170 @@ export const useGraphStore = defineStore('graph', () => {
     lockedStack.value = []
     hoverNodeId.value = null
     hoverPathNodes.value = new Set()
+    hoverPathEdges.value = new Set()
     hoverSource.value = null
   }
 
+  // ==================== 推导图谱 Hover ====================
+  // 计算推导图谱的入边路径(从 targetId 回溯到非组合节点)- 用于激活节点时显示完整入边树
+  function computeDerivationHoverPath(targetId, source = 'derivation') {
+    if (!targetId) {
+      clearHover()
+      return
+    }
+
+    const postGraph = currentPostGraph.value
+    if (!postGraph) return
+
+    // 构建入边索引(只考虑推导边和组成边)
+    const inEdges = new Map()
+    for (const edge of Object.values(postGraph.edges || {})) {
+      if (edge.type !== '推导' && edge.type !== '组成') continue
+      if (!inEdges.has(edge.target)) {
+        inEdges.set(edge.target, [])
+      }
+      inEdges.get(edge.target).push(edge)
+    }
+
+    // 构建节点索引
+    const nodeDataMap = new Map()
+    for (const [nodeId, node] of Object.entries(postGraph.nodes || {})) {
+      nodeDataMap.set(nodeId, node)
+    }
+
+    // BFS 回溯,遇到非组合节点停止
+    const pathNodes = new Set([targetId])
+    const pathEdges = new Set()
+    const queue = [targetId]
+    const visited = new Set([targetId])
+
+    while (queue.length > 0) {
+      const nodeId = queue.shift()
+      const nodeData = nodeDataMap.get(nodeId)
+
+      // 如果当前节点是非组合节点且不是起始节点,不再继续回溯
+      if (nodeId !== targetId && nodeData && nodeData.type !== '组合') {
+        continue
+      }
+
+      const incoming = inEdges.get(nodeId) || []
+      for (const edge of incoming) {
+        pathEdges.add(`${edge.source}->${edge.target}`)
+        pathNodes.add(edge.source)
+
+        if (!visited.has(edge.source)) {
+          visited.add(edge.source)
+          queue.push(edge.source)
+        }
+      }
+    }
+
+    if (pathNodes.size > 0) {
+      hoverNodeId.value = targetId
+      hoverPathNodes.value = pathNodes
+      hoverPathEdges.value = pathEdges
+      hoverSource.value = source
+    }
+  }
+
+  // 计算从 fromId 到 toId 的路径(沿出边方向)- 用于 hover 时显示到激活节点的路径
+  // 路径应该包含至少一个推导边,如果直接路径没有推导边,从 fromId 继续往前找
+  // 返回路径上的节点和边(边用 "source->target" 格式存储)
+  function computeDerivationPathTo(fromId, toId, source = 'derivation') {
+    if (!fromId || !toId) {
+      clearHover()
+      return
+    }
+
+    const postGraph = currentPostGraph.value
+    if (!postGraph) return
+
+    // 构建出边索引和入边索引
+    const outEdges = new Map()
+    const inEdges = new Map()
+    for (const edge of Object.values(postGraph.edges || {})) {
+      if (edge.type !== '推导' && edge.type !== '组成') continue
+      if (!outEdges.has(edge.source)) {
+        outEdges.set(edge.source, [])
+      }
+      outEdges.get(edge.source).push(edge)
+      if (!inEdges.has(edge.target)) {
+        inEdges.set(edge.target, [])
+      }
+      inEdges.get(edge.target).push(edge)
+    }
+
+    // BFS 从 fromId 沿出边方向查找到 toId 的路径,同时记录边
+    const parent = new Map()  // nodeId -> { parentId, edgeType }
+    const queue = [fromId]
+    const visited = new Set([fromId])
+
+    while (queue.length > 0) {
+      const nodeId = queue.shift()
+      if (nodeId === toId) break
+
+      const outgoing = outEdges.get(nodeId) || []
+      for (const edge of outgoing) {
+        if (!visited.has(edge.target)) {
+          visited.add(edge.target)
+          parent.set(edge.target, { parentId: nodeId, edgeType: edge.type })
+          queue.push(edge.target)
+        }
+      }
+    }
+
+    // 回溯路径,记录节点和边
+    const pathNodes = new Set()
+    const pathEdges = new Set()  // 存储路径上的边 "source->target"
+    let hasDerivationEdge = false
+    if (visited.has(toId)) {
+      let curr = toId
+      while (curr) {
+        pathNodes.add(curr)
+        const parentInfo = parent.get(curr)
+        if (parentInfo) {
+          // 记录路径上的边
+          pathEdges.add(`${parentInfo.parentId}->${curr}`)
+          if (parentInfo.edgeType === '推导') {
+            hasDerivationEdge = true
+          }
+          curr = parentInfo.parentId
+        } else {
+          curr = null
+        }
+      }
+    }
+
+    // 如果没有推导边,从 fromId 沿入边方向继续往前找,直到找到推导边
+    if (!hasDerivationEdge && pathNodes.size > 0) {
+      const queue2 = [fromId]
+      const visited2 = new Set([fromId])
+      while (queue2.length > 0 && !hasDerivationEdge) {
+        const nodeId = queue2.shift()
+        const incoming = inEdges.get(nodeId) || []
+        for (const edge of incoming) {
+          pathNodes.add(edge.source)
+          pathEdges.add(`${edge.source}->${nodeId}`)
+          if (edge.type === '推导') {
+            hasDerivationEdge = true
+            break
+          }
+          if (!visited2.has(edge.source)) {
+            visited2.add(edge.source)
+            queue2.push(edge.source)
+          }
+        }
+      }
+    }
+
+    if (pathNodes.size > 0) {
+      hoverNodeId.value = fromId
+      hoverPathNodes.value = pathNodes
+      hoverPathEdges.value = pathEdges
+      hoverSource.value = source
+    }
+  }
+
   // 清除游走结果(双击空白时调用)
   function clearWalk() {
     selectedNodeId.value = null
@@ -884,12 +1050,15 @@ export const useGraphStore = defineStore('graph', () => {
     // Hover 联动
     hoverNodeId,
     hoverPathNodes,
+    hoverPathEdges,
     hoverSource,
     lockedStack,
     lockedHoverNodeId,
     lockedHoverPathNodes,
     lockedHoverStartId,
     computeHoverPath,
+    computeDerivationHoverPath,
+    computeDerivationPathTo,
     clearHover,
     lockCurrentHover,
     clearLockedHover,

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

@@ -88,9 +88,18 @@
   }
 
   .tree-node.highlighted circle,
-  .tree-node.highlighted rect {
-    stroke: #2ecc71;
-    stroke-width: 2;
+  .tree-node.highlighted rect,
+  .match-node.highlighted circle,
+  .match-node.highlighted rect,
+  .walked-node.highlighted circle,
+  .walked-node.highlighted rect {
+    filter: brightness(1.3);
+  }
+
+  .tree-node.highlighted text,
+  .match-node.highlighted text,
+  .walked-node.highlighted text {
+    fill: oklch(var(--p));
   }
 
   /* ========== 统一的置灰样式 ========== */
@@ -203,12 +212,15 @@
   }
 
   .graph-node:hover circle,
-  .graph-node:hover rect {
-    filter: brightness(1.2);
+  .graph-node:hover rect,
+  .graph-node.highlighted:hover circle,
+  .graph-node.highlighted:hover rect {
+    filter: brightness(1.4) !important;
   }
 
-  .graph-node:hover text {
-    fill: oklch(var(--p));
+  .graph-node:hover text,
+  .graph-node.highlighted:hover text {
+    fill: oklch(var(--p)) !important;
   }
 
   .graph-node text {
@@ -239,7 +251,8 @@
   /* 分数标签不阻挡边的点击 */
   .match-score,
   .walked-score,
-  .graph-link-label {
+  .graph-link-label,
+  .derivation-score {
     pointer-events: none;
   }
 
@@ -247,4 +260,54 @@
   .graph-link-label.dimmed {
     opacity: 0.15;
   }
+
+  /* ========== 推导层样式 ========== */
+  /* 推导边 */
+  .derivation-link {
+    fill: none;
+    transition: stroke-opacity 0.2s;
+  }
+
+  .derivation-link:hover {
+    stroke-opacity: 1 !important;
+    stroke-width: 3 !important;
+  }
+
+  .derivation-link.highlighted {
+    stroke-opacity: 0.8 !important;
+    stroke-width: 3 !important;
+  }
+
+  /* 组合节点 */
+  .combo-node {
+    cursor: pointer;
+  }
+
+  .combo-node polygon {
+    transition: all 0.2s;
+  }
+
+  .combo-node:hover polygon {
+    filter: brightness(1.2);
+  }
+
+  .combo-node:hover text {
+    fill: oklch(var(--p));
+  }
+
+  .combo-node text {
+    @apply text-xs;
+    fill: oklch(var(--bc));
+  }
+
+  /* 推导节点高亮 */
+  .derivation-node.highlighted circle,
+  .combo-node.highlighted polygon {
+    filter: brightness(1.3);
+  }
+
+  .derivation-node.highlighted text,
+  .combo-node.highlighted text {
+    fill: oklch(var(--p));
+  }
 }

+ 13 - 3
script/visualization/src/utils/highlight.js

@@ -132,10 +132,20 @@ export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection
  * 清除 hover 效果
  */
 export function clearHoverHighlight(nodeSelection, linkSelection, labelSelection) {
-  nodeSelection.classed('dimmed', false).classed('locked-path', false)
-  linkSelection.classed('dimmed', false).classed('locked-path', false)
+  nodeSelection
+    .classed('dimmed', false)
+    .classed('locked-path', false)
+    .classed('highlighted', false)
+    .classed('selected', false)
+  linkSelection
+    .classed('dimmed', false)
+    .classed('locked-path', false)
+    .classed('highlighted', false)
   if (labelSelection) {
-    labelSelection.classed('dimmed', false).classed('locked-path', false)
+    labelSelection
+      .classed('dimmed', false)
+      .classed('locked-path', false)
+      .classed('highlighted', false)
   }
 }
 

+ 41 - 0
script/visualization/vite.config.js

@@ -20,15 +20,56 @@ console.log('人设节点数:', Object.keys(personaGraphData.nodes || {}).length
 const postGraphDir = process.env.POST_GRAPH_DIR
 let postGraphList = []
 
+// 推导图谱目录(与 post_graph 同级的 node_origin_analysis 目录)
+const derivationGraphDir = postGraphDir ? path.join(path.dirname(postGraphDir), 'node_origin_analysis') : null
+
 if (postGraphDir && fs.existsSync(postGraphDir)) {
   console.log('帖子图谱目录:', postGraphDir)
   const files = fs.readdirSync(postGraphDir).filter(f => f.endsWith('_帖子图谱.json'))
   console.log('帖子图谱文件数:', files.length)
 
+  // 读取推导图谱(如果存在)
+  const derivationGraphs = new Map()
+  if (derivationGraphDir && fs.existsSync(derivationGraphDir)) {
+    console.log('推导图谱目录:', derivationGraphDir)
+    const derivationFiles = fs.readdirSync(derivationGraphDir).filter(f => f.endsWith('_推导图谱.json'))
+    console.log('推导图谱文件数:', derivationFiles.length)
+    for (const file of derivationFiles) {
+      const filePath = path.join(derivationGraphDir, file)
+      const derivationData = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+      const postId = derivationData.meta?.postId
+      if (postId) {
+        derivationGraphs.set(postId, derivationData)
+      }
+    }
+  }
+
   // 读取所有帖子图谱
   for (const file of files) {
     const filePath = path.join(postGraphDir, file)
     const postData = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+
+    // 合并推导图谱(如果存在)
+    const postId = postData.meta?.postId
+    if (postId && derivationGraphs.has(postId)) {
+      const derivation = derivationGraphs.get(postId)
+      console.log(`合并推导图谱到帖子 ${postId}:`, derivation.meta?.stats)
+
+      // 合并节点(组合节点)
+      for (const [nodeId, node] of Object.entries(derivation.nodes || {})) {
+        if (!postData.nodes[nodeId]) {
+          postData.nodes[nodeId] = node
+        }
+      }
+
+      // 合并边(推导边和组成边)
+      for (const [edgeId, edge] of Object.entries(derivation.edges || {})) {
+        if (!postData.edges[edgeId]) {
+          postData.edges[edgeId] = edge
+        }
+      }
+    }
+
     postGraphList.push(postData)
   }