Ver código fonte

feat: 添加标签关系图视图,统一hover和点击行为

- 新增 RelationView.vue 显示标签间的支撑/关联关系
- App.vue 布局调整:关系图与推导图谱上下平分
- edgeStyle.js 添加置灰箭头定义用于hover效果
- highlight.js 添加统一的边hover高亮函数
- GraphView 添加节点hover详情显示
- 非激活状态hover只显示详情不影响其他视图

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 14 horas atrás
pai
commit
5a47d33ff7

+ 9 - 3
script/visualization/src/App.vue

@@ -160,13 +160,18 @@
         class="flex flex-1 min-w-0 transition-all duration-200"
         :class="getMiddleAreaClass()"
       >
-        <!-- 推导图谱 -->
+        <!-- 关系图 + 推导图谱(上下平分) -->
         <div
           v-if="showDerivation"
-          class="bg-base-200 border-l border-base-300 transition-all duration-200"
+          class="bg-base-200 border-l border-base-300 transition-all duration-200 flex flex-col"
           :class="getDerivationPanelClass()"
         >
-          <DerivationView class="h-full" />
+          <div class="h-1/2 border-b border-base-300">
+            <RelationView class="h-full" />
+          </div>
+          <div class="h-1/2">
+            <DerivationView class="h-full" />
+          </div>
         </div>
         <!-- 待解构帖子 -->
         <div
@@ -211,6 +216,7 @@ 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 RelationView from './components/RelationView.vue'
 import { useGraphStore } from './stores/graph'
 import { edgeTypeColors } from './config/edgeStyle'
 

+ 4 - 1
script/visualization/src/components/GraphView.vue

@@ -326,7 +326,10 @@ function renderGraph() {
         d.fy = null
       }))
     .on('mouseenter', function(e, d) {
-      if (d.isCenter) return  // 中心节点不处理
+      // 显示节点详情
+      store.setHoverNode(d)
+
+      if (d.isCenter) return  // 中心节点不处理路径高亮
       // 路径计算由 store 统一处理,标记来源为 graph
       store.computeHoverPath(centerNodeId, d.id, 'graph')
       // 显示锁定按钮(在当前节点上)

+ 479 - 0
script/visualization/src/components/RelationView.vue

@@ -0,0 +1,479 @@
+<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-3">
+        <span class="flex items-center gap-1">
+          <span class="w-3 h-0.5" :style="{ backgroundColor: edgeTypeColors['支撑'] }"></span>
+          <span>支撑 {{ supportCount }}</span>
+        </span>
+        <span class="flex items-center gap-1">
+          <span class="w-3 h-0.5 border-t border-dashed" :style="{ borderColor: edgeTypeColors['关联'] }"></span>
+          <span>关联 {{ relationCount }}</span>
+        </span>
+      </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, watch, onMounted, onUnmounted, nextTick } from 'vue'
+import * as d3 from 'd3'
+import { useGraphStore } from '../stores/graph'
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
+import { edgeTypeColors, getEdgeStyle, createArrowMarkers } from '../config/edgeStyle'
+import { applyHoverHighlight as applyHoverHighlightUtil, applyEdgeHoverHighlight as applyEdgeHoverHighlightUtil, clearHoverHighlight } from '../utils/highlight'
+
+const store = useGraphStore()
+const containerRef = ref(null)
+const svgRef = ref(null)
+
+let simulation = null
+let currentZoom = null
+let mainG = null
+let nodeSelection = null
+let linkSelection = null
+
+// 提取标签节点和关系边
+const graphData = computed(() => {
+  const postGraph = store.currentPostGraph
+  if (!postGraph || !postGraph.nodes || !postGraph.edges) {
+    return { nodes: [], edges: [] }
+  }
+
+  // 提取标签节点
+  const tagNodes = []
+  for (const [id, node] of Object.entries(postGraph.nodes)) {
+    if (node.type === '标签') {
+      tagNodes.push({
+        id,
+        name: node.name,
+        type: node.type,
+        dimension: node.dimension,
+        domain: node.domain || '帖子'
+      })
+    }
+  }
+
+  // 提取支撑边和关联边
+  const relationEdges = []
+  for (const edge of Object.values(postGraph.edges)) {
+    if (edge.type === '支撑' || edge.type === '关联') {
+      relationEdges.push({
+        source: edge.source,
+        target: edge.target,
+        type: edge.type,
+        score: edge.score || 1.0
+      })
+    }
+  }
+
+  return { nodes: tagNodes, edges: relationEdges }
+})
+
+// 统计
+const supportCount = computed(() =>
+  graphData.value.edges.filter(e => e.type === '支撑').length
+)
+const relationCount = computed(() =>
+  graphData.value.edges.filter(e => e.type === '关联').length
+)
+
+// ==================== 事件处理 ====================
+
+// 节点 hover
+function handleNodeHover(event, d) {
+  // 非激活状态:只显示节点详情,不设置全局状态避免影响其他视图
+  if (!store.selectedNodeId) {
+    store.setHoverNode(d)  // 只显示详情面板
+    return
+  }
+
+  // 激活状态:收集相关节点和边,高亮路径
+  store.hoverNodeId = d.id
+  store.hoverSource = 'relation'
+  store.setHoverNode(d)
+
+  // 收集相关节点和边
+  const pathNodes = new Set([d.id])
+  const pathEdges = new Set()
+
+  const { edges } = graphData.value
+  edges.forEach(edge => {
+    if (edge.source.id === d.id || edge.source === d.id) {
+      pathNodes.add(typeof edge.target === 'object' ? edge.target.id : edge.target)
+      pathEdges.add(`${edge.source.id || edge.source}|${edge.type}|${edge.target.id || edge.target}`)
+    }
+    if (edge.target.id === d.id || edge.target === d.id) {
+      pathNodes.add(typeof edge.source === 'object' ? edge.source.id : edge.source)
+      pathEdges.add(`${edge.source.id || edge.source}|${edge.type}|${edge.target.id || edge.target}`)
+    }
+  })
+
+  store.hoverPathNodes = pathNodes
+  store.hoverPathEdges = pathEdges
+  applyHoverHighlight()
+}
+
+// 节点 hover 离开
+function handleNodeHoverOut() {
+  if (store.lockedHoverNodeId) return
+
+  // 清除详情
+  store.clearHoverNode()
+
+  // 只在激活状态下才清除全局状态
+  if (store.selectedNodeId) {
+    store.hoverNodeId = null
+    store.hoverPathNodes = new Set()
+    store.hoverPathEdges = new Set()
+    store.hoverSource = null
+    clearHighlight()
+  }
+}
+
+// 节点点击(激活节点,高亮相关路径,同步到其他视图)
+function handleNodeClick(event, d) {
+  event.stopPropagation()
+
+  // 使用 store.selectNode 统一处理,会同步到其他视图
+  store.selectNode(d)
+
+  // 本地高亮:收集点击节点的相关边和节点
+  const pathNodes = new Set([d.id])
+  const pathEdges = new Set()
+
+  const { edges } = graphData.value
+  edges.forEach(edge => {
+    const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
+    const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
+    if (srcId === d.id || tgtId === d.id) {
+      pathNodes.add(srcId)
+      pathNodes.add(tgtId)
+      pathEdges.add(`${srcId}|${edge.type}|${tgtId}`)
+    }
+  })
+
+  // 应用本地高亮
+  applyClickHighlight(d.id, pathNodes, pathEdges)
+}
+
+// SVG 空白区域点击
+function handleSvgClick(event) {
+  if (event.target === svgRef.value) {
+    store.clearSelection()
+    clearHighlight()
+  }
+}
+
+// 边 hover(本地高亮,不影响其他视图)
+function handleEdgeHover(event, d) {
+  const sourceId = typeof d.source === 'object' ? d.source.id : d.source
+  const targetId = typeof d.target === 'object' ? d.target.id : d.target
+  const edgeId = `${sourceId}|${d.type}|${targetId}`
+
+  // 只设置边详情用于显示,不设置 hoverPathNodes/hoverPathEdges 避免影响其他视图
+  store.setHoverEdge({ ...d, source: sourceId, target: targetId })
+
+  // 本地高亮(包括箭头)
+  applyEdgeHoverHighlight(sourceId, targetId, edgeId, d.type)
+}
+
+// 边 hover 离开
+function handleEdgeHoverOut() {
+  if (store.lockedHoverNodeId) return
+
+  store.clearHoverEdge()
+  clearHighlight()
+}
+
+// 边点击
+function handleEdgeClick(event, d) {
+  event.stopPropagation()
+  const sourceId = typeof d.source === 'object' ? d.source.id : d.source
+  const targetId = typeof d.target === 'object' ? d.target.id : d.target
+  store.selectedEdgeId = `${sourceId}|${d.type}|${targetId}`
+  store.selectedNodeId = null
+}
+
+// ==================== 高亮效果(使用统一工具函数) ====================
+
+function applyHoverHighlight() {
+  if (!nodeSelection || !linkSelection) return
+  applyHoverHighlightUtil(nodeSelection, linkSelection, null, store.hoverPathNodes, null, store.selectedNodeId)
+}
+
+function applyEdgeHoverHighlight(sourceId, targetId, edgeKey, hoveredEdgeType) {
+  if (!nodeSelection || !linkSelection) return
+
+  // 节点高亮
+  nodeSelection
+    .classed('dimmed', d => d.id !== sourceId && d.id !== targetId)
+    .classed('highlighted', d => d.id === sourceId || d.id === targetId)
+
+  // 边高亮(包括箭头)
+  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 thisEdgeKey = `${srcId}|${d.type}|${tgtId}`
+    const isHovered = thisEdgeKey === edgeKey
+
+    d3.select(this)
+      .classed('dimmed', !isHovered)
+      .classed('highlighted', isHovered)
+      // 箭头:高亮边用正常箭头,其他边用置灰箭头
+      .attr('marker-end', d.type === '支撑'
+        ? (isHovered ? `url(#arrow-支撑)` : `url(#arrow-支撑-dimmed)`)
+        : null)
+  })
+}
+
+// 点击节点高亮(高亮节点及其相关边)
+function applyClickHighlight(clickedId, pathNodes, pathEdges) {
+  if (!nodeSelection || !linkSelection) return
+
+  // 节点高亮
+  nodeSelection
+    .classed('dimmed', d => !pathNodes.has(d.id))
+    .classed('selected', d => d.id === clickedId)
+    .classed('highlighted', d => pathNodes.has(d.id) && d.id !== clickedId)
+
+  // 边高亮(包括箭头)
+  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 thisEdgeKey = `${srcId}|${d.type}|${tgtId}`
+    const inPath = pathEdges.has(thisEdgeKey)
+
+    d3.select(this)
+      .classed('dimmed', !inPath)
+      .classed('highlighted', inPath)
+      // 箭头:路径内用正常箭头,其他边用置灰箭头
+      .attr('marker-end', d.type === '支撑'
+        ? (inPath ? `url(#arrow-支撑)` : `url(#arrow-支撑-dimmed)`)
+        : null)
+  })
+}
+
+function clearHighlight() {
+  if (!nodeSelection || !linkSelection) return
+  clearHoverHighlight(nodeSelection, linkSelection, null)
+
+  // 恢复箭头
+  linkSelection.each(function(d) {
+    const style = getEdgeStyle(d)
+    d3.select(this).attr('marker-end', style.markerId ? `url(#${style.markerId})` : null)
+  })
+}
+
+// ==================== 渲染 ====================
+
+function renderGraph() {
+  if (!svgRef.value || !containerRef.value) return
+
+  const { nodes, edges } = graphData.value
+  if (nodes.length === 0) return
+
+  // 清空
+  const svg = d3.select(svgRef.value)
+  svg.selectAll('*').remove()
+
+  const rect = containerRef.value.getBoundingClientRect()
+  const width = rect.width || 400
+  const height = rect.height || 200
+
+  // 创建箭头标记(统一配置)
+  const defs = svg.append('defs')
+  createArrowMarkers(defs)
+
+  // 创建主组
+  mainG = svg.append('g')
+
+  // 缩放
+  currentZoom = d3.zoom()
+    .scaleExtent([0.3, 3])
+    .on('zoom', (e) => mainG.attr('transform', e.transform))
+  svg.call(currentZoom)
+
+  // 深拷贝数据用于 simulation
+  const simNodes = nodes.map(n => ({ ...n }))
+  const simEdges = edges.map(e => ({
+    ...e,
+    source: e.source,
+    target: e.target
+  }))
+
+  // 力导向模拟
+  simulation = d3.forceSimulation(simNodes)
+    .force('link', d3.forceLink(simEdges)
+      .id(d => d.id)
+      .distance(60)
+    )
+    .force('charge', d3.forceManyBody().strength(-150))
+    .force('center', d3.forceCenter(width / 2, height / 2))
+    .force('collision', d3.forceCollide().radius(25))
+
+  // 绘制边
+  linkSelection = mainG.append('g')
+    .attr('class', 'links')
+    .selectAll('line')
+    .data(simEdges)
+    .join('line')
+    .attr('class', 'graph-link')
+    .each(function(d) {
+      const style = getEdgeStyle(d)
+      d3.select(this)
+        .attr('stroke', style.color)
+        .attr('stroke-width', style.strokeWidth)
+        .attr('stroke-opacity', style.opacity)
+        .attr('stroke-dasharray', style.strokeDasharray)
+        .attr('marker-end', style.markerId ? `url(#${style.markerId})` : null)
+    })
+    .on('mouseenter', handleEdgeHover)
+    .on('mouseleave', handleEdgeHoverOut)
+    .on('click', handleEdgeClick)
+
+  // 绘制节点
+  nodeSelection = mainG.append('g')
+    .attr('class', 'nodes')
+    .selectAll('g')
+    .data(simNodes)
+    .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')
+    .each(function(d) {
+      const style = getNodeStyle(d)
+      d3.select(this)
+        .attr('font-size', style.text.fontSize)
+        .attr('fill', style.text.fill)
+        .attr('font-weight', style.text.fontWeight)
+        .text(() => {
+          const name = d.name || ''
+          return name.length > 6 ? name.slice(0, 6) + '…' : 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)
+
+    nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
+  })
+
+  // 拖拽函数
+  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
+  }
+
+  // 初始缩放适应
+  nextTick(() => {
+    setTimeout(() => fitToView(), 300)
+  })
+}
+
+// 适应视图
+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 + 60),
+      height / (bounds.height + 60),
+      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 可能在元素不可见时失败
+  }
+}
+
+// ==================== 监听 ====================
+
+// 监听帖子切换
+watch(() => store.selectedPostIndex, () => {
+  nextTick(() => renderGraph())
+})
+
+watch(() => store.currentPostGraph, () => {
+  nextTick(() => renderGraph())
+}, { deep: true })
+
+// 监听选中状态变化
+watch(() => store.selectedNodeId, (newId) => {
+  if (!nodeSelection) return
+  nodeSelection.classed('selected', d => d.id === newId)
+})
+
+// 监听其他视图的 hover
+watch(() => store.hoverSource, (source) => {
+  if (source && source !== 'relation') {
+    // 其他视图在 hover,同步高亮
+    applyHoverHighlight()
+  } else if (!source) {
+    clearHighlight()
+  }
+})
+
+onMounted(() => {
+  nextTick(() => renderGraph())
+})
+
+onUnmounted(() => {
+  if (simulation) {
+    simulation.stop()
+    simulation = null
+  }
+})
+</script>
+
+<!-- 使用全局 style.css 中的统一样式 -->

+ 50 - 0
script/visualization/src/config/edgeStyle.js

@@ -43,11 +43,61 @@ export function getEdgeStyle(edge) {
     opacity = 0.6
   }
 
+  // 关联边使用虚线
+  if (type === '关联') {
+    strokeDasharray = '4,2'
+  }
+
+  // 箭头配置
+  const hasArrow = ['推导', '支撑'].includes(type)
+  const arrowSize = type === '推导' ? 8 : 6
+  const arrowRefX = type === '推导' ? 20 : 15
+
   return {
     color,
     strokeWidth: type === '推导' ? 2 : 1.5,
     strokeDasharray,
     opacity,
+    hasArrow,
+    arrowSize,
+    arrowRefX,
+    markerId: hasArrow ? `arrow-${type}` : null,
     scoreText: score !== undefined ? score.toFixed(2) : ''
   }
 }
+
+// 创建箭头标记定义(供 SVG defs 使用)
+export function createArrowMarkers(defs) {
+  const arrowTypes = ['推导', '支撑']
+
+  arrowTypes.forEach(type => {
+    const style = getEdgeStyle({ type, score: 1 })
+
+    // 正常箭头
+    defs.append('marker')
+      .attr('id', `arrow-${type}`)
+      .attr('viewBox', '0 -5 10 10')
+      .attr('refX', style.arrowRefX)
+      .attr('refY', 0)
+      .attr('markerWidth', style.arrowSize)
+      .attr('markerHeight', style.arrowSize)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-5L10,0L0,5')
+      .attr('fill', style.color)
+
+    // 置灰箭头(用于 hover 时其他边)
+    defs.append('marker')
+      .attr('id', `arrow-${type}-dimmed`)
+      .attr('viewBox', '0 -5 10 10')
+      .attr('refX', style.arrowRefX)
+      .attr('refY', 0)
+      .attr('markerWidth', style.arrowSize)
+      .attr('markerHeight', style.arrowSize)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-5L10,0L0,5')
+      .attr('fill', style.color)
+      .attr('fill-opacity', 0.15)
+  })
+}

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

@@ -130,6 +130,57 @@ export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection
   }
 }
 
+/**
+ * 应用边 hover 高亮:只高亮当前边和两个端点,其他全部置灰
+ * @param {D3Selection} nodeSelection - 节点选择集
+ * @param {D3Selection} linkSelection - 边选择集
+ * @param {D3Selection} labelSelection - 标签选择集(可选)
+ * @param {string} sourceId - 边的源节点ID
+ * @param {string} targetId - 边的目标节点ID
+ * @param {string} edgeKey - 边的唯一标识(用于匹配)
+ */
+export function applyEdgeHoverHighlight(nodeSelection, linkSelection, labelSelection, sourceId, targetId, edgeKey) {
+  // 节点:只高亮两个端点
+  nodeSelection
+    .classed('dimmed', d => {
+      const id = getNodeId(d)
+      return id !== sourceId && id !== targetId
+    })
+    .classed('highlighted', d => {
+      const id = getNodeId(d)
+      return id === sourceId || id === targetId
+    })
+    .classed('selected', false)
+    .classed('locked-path', false)
+
+  // 边:只高亮当前边
+  linkSelection
+    .classed('dimmed', l => {
+      const { srcId, tgtId } = getLinkIds(l)
+      const lEdgeKey = `${srcId}|${l.type}|${tgtId}`
+      const lEdgeKeyAlt = `${srcId}->${tgtId}`
+      return lEdgeKey !== edgeKey && lEdgeKeyAlt !== edgeKey
+    })
+    .classed('highlighted', l => {
+      const { srcId, tgtId } = getLinkIds(l)
+      const lEdgeKey = `${srcId}|${l.type}|${tgtId}`
+      const lEdgeKeyAlt = `${srcId}->${tgtId}`
+      return lEdgeKey === edgeKey || lEdgeKeyAlt === edgeKey
+    })
+    .classed('locked-path', false)
+
+  if (labelSelection) {
+    labelSelection
+      .classed('dimmed', l => {
+        const { srcId, tgtId } = getLinkIds(l)
+        const lEdgeKey = `${srcId}|${l.type}|${tgtId}`
+        const lEdgeKeyAlt = `${srcId}->${tgtId}`
+        return lEdgeKey !== edgeKey && lEdgeKeyAlt !== edgeKey
+      })
+      .classed('locked-path', false)
+  }
+}
+
 /**
  * 清除 hover 效果
  */