Browse Source

feat: 标签关系图高亮同步到相关图

- 添加 relationHighlight 状态用于存储标签关系图的高亮节点和边
- 将 relationHighlight 合并到 mergedHighlightNodes/Edges
- RelationView 点击节点时调用 setRelationHighlight 同步高亮
- GraphView 支持 relation 来源的 hover 缩放

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 9 hours ago
parent
commit
9d1ea65c52

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

@@ -580,11 +580,14 @@ watch(() => store.highlightedNodeIds.size, updateHighlight)
 
 // 监听合并高亮变化(各图高亮变化时自动重新渲染)
 watch([() => store.mergedHighlightNodes.size, () => store.mergedHighlightEdges.length], () => {
+  console.log('[GraphView] watch mergedHighlight:', store.mergedHighlightNodes.size, 'nodes,', store.mergedHighlightEdges.length, 'edges')
   nextTick(() => {
     renderGraph()
-    // 渲染后缩放到所有高亮节点
+    // 渲染后应用高亮并缩放
     nextTick(() => {
+      updateHighlight()  // 重要:渲染后需要重新应用高亮
       if (store.mergedHighlightNodes.size > 0) {
+        console.log('[GraphView] zoomToPathNodes:', store.mergedHighlightNodes.size)
         zoomToPathNodes(store.mergedHighlightNodes)
       }
     })
@@ -614,7 +617,7 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath, store.selectedNodeId)
 
   // 如果是从其他视图触发的 hover,缩放到显示完整路径
-  if (store.hoverSource === 'post-tree' || store.hoverSource === 'derivation') {
+  if (store.hoverSource === 'post-tree' || store.hoverSource === 'derivation' || store.hoverSource === 'relation') {
     zoomToPathNodes(store.hoverPathNodes)
   }
 

+ 250 - 41
script/visualization/src/components/RelationView.vue

@@ -39,6 +39,11 @@ let mainG = null
 let nodeSelection = null
 let linkSelection = null
 
+// 本地选中状态(用于激活状态下的hover和高亮恢复)
+const selectedNodeId = ref(null)
+const selectedPathNodes = ref(new Set())
+const selectedPathEdges = ref(new Set())
+
 // 提取标签节点和关系边
 const graphData = computed(() => {
   const postGraph = store.currentPostGraph
@@ -86,69 +91,125 @@ const relationCount = computed(() =>
 
 // ==================== 事件处理 ====================
 
+// 在已高亮路径中查找从 fromId 到 toId 的子路径(BFS)
+function computePathInHighlighted(fromId, toId) {
+  if (!selectedPathNodes.value.has(fromId)) return { nodes: new Set(), edges: new Set() }
+
+  const { edges } = graphData.value
+
+  // 构建邻接表(只包含已高亮的边)
+  const adj = new Map()
+  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
+    const edgeKey = `${srcId}|${edge.type}|${tgtId}`
+
+    if (!selectedPathEdges.value.has(edgeKey)) return
+
+    if (!adj.has(srcId)) adj.set(srcId, [])
+    if (!adj.has(tgtId)) adj.set(tgtId, [])
+    adj.get(srcId).push({ neighbor: tgtId, edge, edgeKey })
+    adj.get(tgtId).push({ neighbor: srcId, edge, edgeKey })
+  })
+
+  // BFS 找路径
+  const visited = new Set([fromId])
+  const parent = new Map()
+  const parentEdgeKey = 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)
+        parentEdgeKey.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 ek = parentEdgeKey.get(curr)
+      if (ek) pathEdges.add(ek)
+      curr = parent.get(curr)
+    }
+  }
+
+  return { nodes: pathNodes, edges: pathEdges }
+}
+
 // 节点 hover
 function handleNodeHover(event, d) {
-  // 非激活状态:只显示节点详情,不设置全局状态避免影响其他视图
-  if (!store.selectedNodeId) {
-    store.setHoverNode(d)  // 只显示详情面板
+  // 非激活状态:只显示节点详情
+  if (!selectedNodeId.value) {
+    store.setHoverNode(d)
     return
   }
 
-  // 激活状态:收集相关节点和边,高亮路径
-  store.hoverNodeId = d.id
-  store.hoverSource = 'relation'
-  store.setHoverNode(d)
+  // 不处理选中节点自身
+  if (d.id === selectedNodeId.value) return
 
-  // 收集相关节点和边
-  const pathNodes = new Set([d.id])
-  const pathEdges = new Set()
+  // 激活状态:在已高亮路径中查找子路径
+  const { nodes: pathNodes, edges: pathEdges } = computePathInHighlighted(d.id, selectedNodeId.value)
 
-  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}`)
-    }
-  })
+  // 如果找不到路径,不做任何处理
+  if (pathNodes.size === 0) return
 
+  // 更新 store 状态用于联动
+  store.hoverNodeId = d.id
   store.hoverPathNodes = pathNodes
   store.hoverPathEdges = pathEdges
-  applyHoverHighlight()
+  store.hoverSource = 'relation'
+  store.setHoverNode(d)
+
+  applyHoverHighlightWithArrows(pathNodes, pathEdges)
 }
 
 // 节点 hover 离开
 function handleNodeHoverOut() {
+  // 非激活状态:只清除详情
+  if (!selectedNodeId.value) {
+    store.clearHoverNode()
+    return
+  }
+
   if (store.lockedHoverNodeId) return
 
-  // 清除详情
+  store.clearHover()
   store.clearHoverNode()
 
-  // 只在激活状态下才清除全局状态
-  if (store.selectedNodeId) {
-    store.hoverNodeId = null
-    store.hoverPathNodes = new Set()
-    store.hoverPathEdges = new Set()
-    store.hoverSource = null
-    clearHighlight()
-  }
+  // 恢复到选中状态的高亮
+  applyClickHighlight(selectedNodeId.value, selectedPathNodes.value, selectedPathEdges.value)
 }
 
 // 节点点击(激活节点,高亮相关路径,同步到其他视图)
 function handleNodeClick(event, d) {
   event.stopPropagation()
 
-  // 使用 store.selectNode 统一处理,会同步到其他视图
-  store.selectNode(d)
+  console.log('[RelationView] handleNodeClick:', d.id)
+
+  // 先保存本地选中状态(防止 watch 干扰)
+  selectedNodeId.value = d.id
 
-  // 本地高亮:收集点击节点的相关边和节点
+  // 收集点击节点的相关边和节点
   const pathNodes = new Set([d.id])
   const pathEdges = new Set()
+  const edgesForStore = []  // 用于同步到 store 的边对象数组
 
   const { edges } = graphData.value
+  console.log('[RelationView] graphData.edges count:', edges.length)
+
   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
@@ -156,17 +217,48 @@ function handleNodeClick(event, d) {
       pathNodes.add(srcId)
       pathNodes.add(tgtId)
       pathEdges.add(`${srcId}|${edge.type}|${tgtId}`)
+      edgesForStore.push({ source: srcId, target: tgtId, type: edge.type, score: edge.score })
+      console.log('[RelationView] found edge:', srcId, '->', tgtId, 'type:', edge.type)
     }
   })
 
+  console.log('[RelationView] pathNodes:', [...pathNodes])
+  console.log('[RelationView] pathEdges:', [...pathEdges])
+
+  // 保存选中路径(用于 hover 时恢复)
+  selectedPathNodes.value = pathNodes
+  selectedPathEdges.value = pathEdges
+
   // 应用本地高亮
+  console.log('[RelationView] calling applyClickHighlight')
   applyClickHighlight(d.id, pathNodes, pathEdges)
+
+  // 设置关系图高亮到 store(用于相关图合并显示)
+  store.setRelationHighlight(pathNodes, edgesForStore)
+  console.log('[RelationView] setRelationHighlight:', pathNodes.size, 'nodes,', edgesForStore.length, 'edges')
+
+  // 设置 store 状态,用于联动其他视图(如 GraphView)
+  store.hoverNodeId = d.id
+  store.hoverPathNodes = pathNodes
+  store.hoverPathEdges = pathEdges
+  store.hoverSource = 'relation'
+
+  // 最后通知 store(触发其他视图联动)
+  store.selectNode(d)
 }
 
 // SVG 空白区域点击
 function handleSvgClick(event) {
   if (event.target === svgRef.value) {
+    // 清除本地状态
+    selectedNodeId.value = null
+    selectedPathNodes.value = new Set()
+    selectedPathEdges.value = new Set()
+
+    // 清除 store 状态
+    store.clearHover()
     store.clearSelection()
+    store.clearRelationHighlight()  // 清除关系图高亮
     clearHighlight()
   }
 }
@@ -233,11 +325,54 @@ function applyEdgeHoverHighlight(sourceId, targetId, edgeKey, hoveredEdgeType) {
   })
 }
 
+// hover 高亮(只高亮 hover 路径,其他全部置灰,与 DerivationView 一致)
+function applyHoverHighlightWithArrows(pathNodes, pathEdges) {
+  if (!nodeSelection || !linkSelection) return
+
+  // 节点高亮:只有 hover 路径上的节点不置灰
+  nodeSelection
+    .classed('dimmed', d => !pathNodes.has(d.id))
+    .classed('highlighted', d => pathNodes.has(d.id) && d.id !== selectedNodeId.value)
+    .classed('selected', d => d.id === selectedNodeId.value)
+    .classed('locked-path', false)
+
+  // 边高亮(包括箭头):只有 hover 路径上的边不置灰
+  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 inHoverPath = pathEdges.has(thisEdgeKey)
+
+    d3.select(this)
+      .classed('dimmed', !inHoverPath)
+      .classed('highlighted', inHoverPath)
+      .classed('locked-path', false)
+      .attr('marker-end', d.type === '支撑'
+        ? (inHoverPath ? `url(#arrow-支撑)` : `url(#arrow-支撑-dimmed)`)
+        : null)
+  })
+}
+
 // 点击节点高亮(高亮节点及其相关边)
 function applyClickHighlight(clickedId, pathNodes, pathEdges) {
-  if (!nodeSelection || !linkSelection) return
+  console.log('[RelationView] applyClickHighlight:', clickedId, 'pathNodes:', [...pathNodes], 'pathEdges:', [...pathEdges])
+
+  if (!nodeSelection || !linkSelection) {
+    console.log('[RelationView] applyClickHighlight: no selection!')
+    return
+  }
+
+  console.log('[RelationView] nodeSelection size:', nodeSelection.size())
+  console.log('[RelationView] linkSelection size:', linkSelection.size())
 
   // 节点高亮
+  nodeSelection.each(function(d) {
+    const isDimmed = !pathNodes.has(d.id)
+    const isSelected = d.id === clickedId
+    const isHighlighted = pathNodes.has(d.id) && d.id !== clickedId
+    console.log('[RelationView] node:', d.id, 'dimmed:', isDimmed, 'selected:', isSelected, 'highlighted:', isHighlighted)
+  })
+
   nodeSelection
     .classed('dimmed', d => !pathNodes.has(d.id))
     .classed('selected', d => d.id === clickedId)
@@ -249,6 +384,7 @@ function applyClickHighlight(clickedId, pathNodes, pathEdges) {
     const tgtId = typeof d.target === 'object' ? d.target.id : d.target
     const thisEdgeKey = `${srcId}|${d.type}|${tgtId}`
     const inPath = pathEdges.has(thisEdgeKey)
+    console.log('[RelationView] edge:', srcId, '->', tgtId, 'key:', thisEdgeKey, 'inPath:', inPath)
 
     d3.select(this)
       .classed('dimmed', !inPath)
@@ -258,6 +394,8 @@ function applyClickHighlight(clickedId, pathNodes, pathEdges) {
         ? (inPath ? `url(#arrow-支撑)` : `url(#arrow-支撑-dimmed)`)
         : null)
   })
+
+  console.log('[RelationView] applyClickHighlight done')
 }
 
 function clearHighlight() {
@@ -448,18 +586,89 @@ watch(() => store.currentPostGraph, () => {
   nextTick(() => renderGraph())
 }, { deep: true })
 
-// 监听选中状态变化
-watch(() => store.selectedNodeId, (newId) => {
-  if (!nodeSelection) return
-  nodeSelection.classed('selected', d => d.id === newId)
+// 监听选中状态变化(联动其他视图的点击)
+watch(() => store.selectedNodeId, (newId, oldId) => {
+  console.log('[RelationView] watch selectedNodeId:', newId, 'oldId:', oldId, 'local:', selectedNodeId.value)
+
+  if (!nodeSelection || !linkSelection) {
+    console.log('[RelationView] watch: no selection, skip')
+    return
+  }
+
+  // 如果是从本视图内部点击触发的,不重复处理
+  if (selectedNodeId.value === newId) {
+    console.log('[RelationView] watch: same as local, skip')
+    return
+  }
+
+  console.log('[RelationView] watch: processing external selection')
+
+  // 先清除旧状态
+  if (oldId && selectedNodeId.value) {
+    selectedNodeId.value = null
+    selectedPathNodes.value = new Set()
+    selectedPathEdges.value = new Set()
+    clearHighlight()
+  }
+
+  // 检查新节点是否在本视图中
+  const nodeInGraph = graphData.value.nodes.find(n => n.id === newId)
+
+  if (nodeInGraph) {
+    // 节点在本视图中,激活并显示高亮
+    selectedNodeId.value = newId
+
+    // 计算相关路径
+    const pathNodes = new Set([newId])
+    const pathEdges = new Set()
+    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 === newId || tgtId === newId) {
+        pathNodes.add(srcId)
+        pathNodes.add(tgtId)
+        pathEdges.add(`${srcId}|${edge.type}|${tgtId}`)
+      }
+    })
+
+    selectedPathNodes.value = pathNodes
+    selectedPathEdges.value = pathEdges
+    applyClickHighlight(newId, pathNodes, pathEdges)
+  } else if (newId) {
+    // 节点不在本视图中,清除本地状态
+    selectedNodeId.value = null
+    selectedPathNodes.value = new Set()
+    selectedPathEdges.value = new Set()
+    clearHighlight()
+  } else {
+    // newId 为 null,清除所有状态
+    selectedNodeId.value = null
+    selectedPathNodes.value = new Set()
+    selectedPathEdges.value = new Set()
+    clearHighlight()
+  }
 })
 
 // 监听其他视图的 hover
 watch(() => store.hoverSource, (source) => {
+  console.log('[RelationView] watch hoverSource:', source, 'localSelected:', selectedNodeId.value)
+
+  if (!nodeSelection || !linkSelection) return
+
+  // 如果本视图有激活状态,不响应其他视图的 hover,保持本地高亮
+  if (selectedNodeId.value && selectedPathNodes.value.size > 0) {
+    console.log('[RelationView] watch hoverSource: has local selection, keep local highlight')
+    return
+  }
+
   if (source && source !== 'relation') {
-    // 其他视图在 hover,同步高亮
-    applyHoverHighlight()
+    console.log('[RelationView] watch hoverSource: other view hover')
+    // 其他视图在 hover,使用 store 的 pathNodes 高亮
+    if (store.hoverPathNodes.size > 0) {
+      applyHoverHighlight()
+    }
   } else if (!source) {
+    console.log('[RelationView] watch hoverSource: cleared')
     clearHighlight()
   }
 })

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

@@ -493,6 +493,9 @@ export const useGraphStore = defineStore('graph', () => {
     postWalkedNodes.value = Array.from(allNodes.values())
     postWalkedEdges.value = Array.from(allEdges.values())
 
+    console.log('[Store] postWalkedNodes set:', postWalkedNodes.value.length, 'nodes')
+    console.log('[Store] postWalkedEdges set:', postWalkedEdges.value.length, 'edges')
+
     // 返回高亮节点集合
     const highlightedIds = new Set([startNodeId])
     for (const node of allNodes.keys()) {
@@ -563,6 +566,8 @@ export const useGraphStore = defineStore('graph', () => {
   // ==================== 各图独立的高亮状态(用于相关图合并) ====================
   // 推导图谱的高亮路径
   const derivationHighlight = ref({ nodes: new Set(), edges: [] })
+  // 标签关系图的高亮路径
+  const relationHighlight = ref({ nodes: new Set(), edges: [] })
   // 待解构图的高亮路径(复用 postWalkedNodes/postWalkedEdges)
   // 人设图的高亮路径(复用 walkedEdges 和 highlightedNodeIds)
 
@@ -576,6 +581,16 @@ export const useGraphStore = defineStore('graph', () => {
     derivationHighlight.value = { nodes: new Set(), edges: [] }
   }
 
+  // 设置标签关系图高亮
+  function setRelationHighlight(nodes, edges) {
+    relationHighlight.value = { nodes: new Set(nodes), edges: [...edges] }
+  }
+
+  // 清除标签关系图高亮
+  function clearRelationHighlight() {
+    relationHighlight.value = { nodes: new Set(), edges: [] }
+  }
+
   // 合并后的高亮节点(相关图使用)
   const mergedHighlightNodes = computed(() => {
     const merged = new Set()
@@ -585,6 +600,8 @@ export const useGraphStore = defineStore('graph', () => {
     for (const node of postWalkedNodes.value) merged.add(node.id || node)
     // 3. 推导图谱的节点
     for (const id of derivationHighlight.value.nodes) merged.add(id)
+    // 4. 标签关系图的节点
+    for (const id of relationHighlight.value.nodes) merged.add(id)
     return merged
   })
 
@@ -606,6 +623,8 @@ export const useGraphStore = defineStore('graph', () => {
     for (const edge of postWalkedEdges.value) addEdge(edge)
     // 3. 推导图谱的边
     for (const edge of derivationHighlight.value.edges) addEdge(edge)
+    // 4. 标签关系图的边
+    for (const edge of relationHighlight.value.edges) addEdge(edge)
     return merged
   })
 
@@ -1202,6 +1221,9 @@ export const useGraphStore = defineStore('graph', () => {
     derivationHighlight,
     setDerivationHighlight,
     clearDerivationHighlight,
+    relationHighlight,
+    setRelationHighlight,
+    clearRelationHighlight,
     mergedHighlightNodes,
     mergedHighlightEdges,
     // Hover 联动