瀏覽代碼

feat: 优化视图联动和选中状态同步

- 点击待解构帖子节点时,推导图谱自动缩放到完整显示高亮子图
- 添加 zoomToSubgraph 函数计算路径边界框并平滑过渡
- 修复选中节点样式在各视图间不同步的问题
- applyHoverHighlight 添加 selectedNodeId 参数保持选中样式
- 修复 GraphView hover 清除后选中节点样式丢失问题
- 详情面板节点指示器添加激活样式(brightness + glow)

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 11 小時之前
父節點
當前提交
97293b4803

+ 49 - 1
script/visualization/src/components/DerivationView.vue

@@ -798,10 +798,12 @@ function applyDerivationHighlight() {
     .classed('highlighted', false)
     .classed('highlighted', false)
 
 
   // 节点高亮
   // 节点高亮
+  // selected: 本地选中节点或全局选中节点(来自其他视图)
+  const activeNodeId = selectedNodeId.value || store.selectedNodeId
   nodeSelection
   nodeSelection
     .classed('dimmed', d => !allPathNodes.has(d.id))
     .classed('dimmed', d => !allPathNodes.has(d.id))
     .classed('locked-path', d => lockedPathNodes && lockedPathNodes.has(d.id) && !pathNodes.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 判断,而不是节点)
   // 边高亮(使用 pathEdges 判断,而不是节点)
   linkSelection.each(function(d) {
   linkSelection.each(function(d) {
@@ -1120,6 +1122,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) {
 function dragstarted(event, d) {
   if (!event.active) simulation.alphaTarget(0.3).restart()
   if (!event.active) simulation.alphaTarget(0.3).restart()
@@ -1193,6 +1238,9 @@ watch(() => store.selectedNodeId, (newId) => {
     store.hoverPathEdges = pathEdges
     store.hoverPathEdges = pathEdges
     store.hoverSource = 'derivation'
     store.hoverSource = 'derivation'
     applyDerivationHighlight()
     applyDerivationHighlight()
+
+    // 缩放到高亮子图(完整显示路径)
+    zoomToSubgraph(pathNodes)
   } else {
   } else {
     // 节点不在推导图谱中,清除选中状态
     // 节点不在推导图谱中,清除选中状态
     selectedNodeId.value = null
     selectedNodeId.value = null

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

@@ -18,8 +18,15 @@
       <div v-if="store.selectedNode" class="space-y-3">
       <div v-if="store.selectedNode" class="space-y-3">
         <div class="flex items-center gap-2">
         <div class="flex items-center gap-2">
           <span
           <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>
           ></span>
           <h4 class="text-primary font-medium truncate">{{ store.selectedNode.name }}</h4>
           <h4 class="text-primary font-medium truncate">{{ store.selectedNode.name }}</h4>
         </div>
         </div>
@@ -61,7 +68,7 @@
 <script setup>
 <script setup>
 import { computed, h } from 'vue'
 import { computed, h } from 'vue'
 import { useGraphStore } from '../stores/graph'
 import { useGraphStore } from '../stores/graph'
-import { dimColors } from '../config/nodeStyle'
+import { getNodeStyle } from '../config/nodeStyle'
 import { edgeTypeColors } from '../config/edgeStyle'
 import { edgeTypeColors } from '../config/edgeStyle'
 
 
 const store = useGraphStore()
 const store = useGraphStore()
@@ -69,11 +76,10 @@ const store = useGraphStore()
 // 是否有选中内容
 // 是否有选中内容
 const hasSelection = computed(() => store.selectedNode || store.selectedEdge)
 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)
 })
 })
 
 
 // 边颜色
 // 边颜色

+ 7 - 7
script/visualization/src/components/GraphView.vue

@@ -338,7 +338,7 @@ function renderGraph() {
       if (store.lockedHoverNodeId) {
       if (store.lockedHoverNodeId) {
         // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
         // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
         // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
         // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
-        applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, null)
+        applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, null, store.selectedNodeId)
         // 在锁定节点上显示解锁按钮
         // 在锁定节点上显示解锁按钮
         graphNodeSelection.each(function(d) {
         graphNodeSelection.each(function(d) {
           if (d.id === store.lockedHoverNodeId) {
           if (d.id === store.lockedHoverNodeId) {
@@ -540,10 +540,10 @@ function updateHighlight() {
 function restoreLockedHover() {
 function restoreLockedHover() {
   if (!store.lockedHoverNodeId || !graphNodeSelection) return
   if (!store.lockedHoverNodeId || !graphNodeSelection) return
 
 
-  // 恢复高亮效果(传入锁定路径)
+  // 恢复高亮效果(传入锁定路径和选中节点
   if (store.hoverPathNodes.size > 0) {
   if (store.hoverPathNodes.size > 0) {
     const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
     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 元素
   // 恢复锁定按钮:找到锁定节点的 DOM 元素
@@ -573,9 +573,9 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   if (!graphNodeSelection || !graphLinkSelection) return
   if (!graphNodeSelection || !graphLinkSelection) return
 
 
   if (store.hoverPathNodes.size > 0) {
   if (store.hoverPathNodes.size > 0) {
-    // 应用 hover 高亮(支持嵌套:传入锁定路径)
+    // 应用 hover 高亮(支持嵌套:传入锁定路径和选中节点
     const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
     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)
 
 
     // 如果是从 PostTreeView 触发的,缩放到显示完整路径,并显示锁定按钮
     // 如果是从 PostTreeView 触发的,缩放到显示完整路径,并显示锁定按钮
     if (store.hoverSource === 'post-tree') {
     if (store.hoverSource === 'post-tree') {
@@ -591,8 +591,8 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
       })
       })
     }
     }
   } else {
   } else {
-    // 清除 hover,恢复原有高亮
-    clearHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection)
+    // 清除 hover,恢复原有高亮(包括选中节点的样式)
+    updateHighlight()
     // 如果没有锁定,隐藏按钮
     // 如果没有锁定,隐藏按钮
     if (!store.lockedHoverNodeId) {
     if (!store.lockedHoverNodeId) {
       hideLockButton()
       hideLockButton()

+ 24 - 14
script/visualization/src/components/PostTreeView.vue

@@ -135,13 +135,18 @@
               <div class="flex items-center gap-1.5">
               <div class="flex items-center gap-1.5">
                 <!-- hover 标识 -->
                 <!-- hover 标识 -->
                 <span v-if="store.hoverNode" class="text-[9px] text-warning/60">[hover]</span>
                 <span v-if="store.hoverNode" class="text-[9px] text-warning/60">[hover]</span>
-                <!-- 节点样式:空心(帖子域)或实心(人设域) -->
+                <!-- 节点样式:空心(帖子域)或实心(人设域),激活状态有发光效果 -->
                 <span
                 <span
                   class="w-2 h-2 shrink-0"
                   class="w-2 h-2 shrink-0"
                   :class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
                   :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>
                 <span class="text-primary font-medium text-[11px] break-all">{{ displayNode.name }}</span>
                 <span class="text-primary font-medium text-[11px] break-all">{{ displayNode.name }}</span>
               </div>
               </div>
@@ -1821,10 +1826,9 @@ function applyPathHighlightWithEdges(pathNodes, pathEdges) {
   const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
   const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
   const allLabels = svg.selectAll('.match-score, .walked-score')
   const allLabels = svg.selectAll('.match-score, .walked-score')
 
 
-  // 先清除所有残留的高亮类
+  // 先清除所有残留的高亮类(但保留 selected 类给选中节点)
   allNodes
   allNodes
     .classed('highlighted', false)
     .classed('highlighted', false)
-    .classed('selected', false)
     .classed('locked-path', false)
     .classed('locked-path', false)
   allLinks
   allLinks
     .classed('highlighted', false)
     .classed('highlighted', false)
@@ -1833,11 +1837,17 @@ function applyPathHighlightWithEdges(pathNodes, pathEdges) {
     .classed('highlighted', false)
     .classed('highlighted', false)
     .classed('locked-path', false)
     .classed('locked-path', false)
 
 
-  // 节点高亮
-  allNodes.classed('dimmed', function(d) {
-    const nodeId = d.data?.id || d.id
-    return !pathNodes.has(nodeId)
-  })
+  // 节点高亮(保持选中节点的 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) {
   allLinks.each(function(d) {
@@ -1890,7 +1900,7 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
     const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
     const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
     const allLabels = svg.selectAll('.match-score, .walked-score')
     const allLabels = svg.selectAll('.match-score, .walked-score')
     const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
     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)
 
 
     if (store.hoverNodeId) {
     if (store.hoverNodeId) {
       const nodeInfo = nodeElements[store.hoverNodeId]
       const nodeInfo = nodeElements[store.hoverNodeId]
@@ -1975,10 +1985,10 @@ function restoreLockedHover() {
   const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
   const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
   const allLabels = svg.selectAll('.match-score, .walked-score')
   const allLabels = svg.selectAll('.match-score, .walked-score')
 
 
-  // 恢复高亮效果(传入锁定路径)
+  // 恢复高亮效果(传入锁定路径和选中节点
   if (store.hoverPathNodes.size > 0) {
   if (store.hoverPathNodes.size > 0) {
     const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
     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))
       treeNode.children.forEach(child => neighborNodes.add(child.data.id))
     }
     }
 
 
-    // 应用高亮(只高亮上下游节点和连接它们的边)
-    applyHoverHighlight(allNodes, allLinks, null, neighborNodes)
+    // 应用高亮(只高亮上下游节点和连接它们的边,保持选中节点样式
+    applyHoverHighlight(allNodes, allLinks, null, neighborNodes, null, store.selectedNodeId)
 
 
     // 缩放到所有上下游节点都可见
     // 缩放到所有上下游节点都可见
     zoomToNodes(neighborNodes)
     zoomToNodes(neighborNodes)

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

@@ -80,8 +80,9 @@ export function findPath(startId, endId, links) {
  * @param {D3Selection} labelSelection - 标签选择集(可选)
  * @param {D3Selection} labelSelection - 标签选择集(可选)
  * @param {Set} pathNodes - 当前 hover 路径上的节点ID集合
  * @param {Set} pathNodes - 当前 hover 路径上的节点ID集合
  * @param {Set} lockedPathNodes - 锁定路径上的节点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
   // 合并路径:锁定 + hover
   const allPathNodes = new Set([...pathNodes])
   const allPathNodes = new Set([...pathNodes])
   if (lockedPathNodes) {
   if (lockedPathNodes) {
@@ -90,13 +91,14 @@ export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection
     }
     }
   }
   }
 
 
-  // 节点:不在任何路径中的置灰,只在锁定路径中的半透明
+  // 节点:不在任何路径中的置灰,只在锁定路径中的半透明,保持选中节点样式
   nodeSelection
   nodeSelection
     .classed('dimmed', d => !allPathNodes.has(getNodeId(d)))
     .classed('dimmed', d => !allPathNodes.has(getNodeId(d)))
     .classed('locked-path', d => {
     .classed('locked-path', d => {
       const id = getNodeId(d)
       const id = getNodeId(d)
       return lockedPathNodes && lockedPathNodes.has(id) && !pathNodes.has(id)
       return lockedPathNodes && lockedPathNodes.has(id) && !pathNodes.has(id)
     })
     })
+    .classed('selected', d => selectedNodeId && getNodeId(d) === selectedNodeId)
 
 
   // 边:不在任何路径中的置灰,只在锁定路径中的半透明
   // 边:不在任何路径中的置灰,只在锁定路径中的半透明
   linkSelection
   linkSelection