Jelajahi Sumber

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

yangxiaohui 1 hari lalu
induk
melakukan
b9cda33396

+ 70 - 4
script/visualization/src/components/DerivationView.vue

@@ -176,6 +176,8 @@ let linkLabelSelection = null
 
 // 选中的节点(激活状态)
 const selectedNodeId = ref(null)
+// 是否正在 hover 边(防止 watch 覆盖边 hover 高亮)
+let isEdgeHovering = false
 // 选中节点的路径(本地状态,不受其他视图 hover 影响)
 const selectedPathNodes = ref(new Set())
 const selectedPathEdges = ref(new Set())
@@ -776,6 +778,65 @@ function applyDerivationHighlight() {
   }
 }
 
+// 边 hover 高亮(只高亮当前边和两个端点,其他全部置灰包括箭头)
+function applyEdgeHoverHighlight(edge) {
+  if (!nodeSelection || !linkSelection) return
+
+  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}->${tgtId}`
+
+  // 节点:只高亮两个端点,清除其他状态
+  nodeSelection
+    .classed('dimmed', d => d.id !== srcId && d.id !== tgtId)
+    .classed('selected', false)
+    .classed('locked-path', false)
+
+  // 边:只高亮当前边,其他边置灰(包括箭头),清除 highlighted 状态
+  linkSelection.each(function(d) {
+    const dSrcId = typeof d.source === 'object' ? d.source.id : d.source
+    const dTgtId = typeof d.target === 'object' ? d.target.id : d.target
+    const dEdgeKey = `${dSrcId}->${dTgtId}`
+    const isDimmed = dEdgeKey !== edgeKey
+    d3.select(this)
+      .classed('dimmed', isDimmed)
+      .classed('highlighted', !isDimmed)
+      .classed('locked-path', false)
+      .attr('marker-end', isDimmed ? `url(#arrow-${d.type}-dimmed)` : `url(#arrow-${d.type})`)
+  })
+
+  // 标签:只高亮当前边的标签
+  if (linkLabelSelection) {
+    linkLabelSelection.each(function(d) {
+      const dSrcId = typeof d.source === 'object' ? d.source.id : d.source
+      const dTgtId = typeof d.target === 'object' ? d.target.id : d.target
+      const dEdgeKey = `${dSrcId}->${dTgtId}`
+      d3.select(this)
+        .classed('dimmed', dEdgeKey !== edgeKey)
+        .classed('locked-path', false)
+    })
+  }
+}
+
+// 清除边 hover 高亮
+function clearEdgeHoverHighlight() {
+  if (!nodeSelection || !linkSelection) return
+
+  // 如果有选中节点,恢复到选中状态的高亮
+  if (selectedNodeId.value) {
+    applySelectedHighlight()
+  } else {
+    // 否则清除所有高亮,恢复箭头
+    nodeSelection.classed('dimmed', false)
+    linkSelection
+      .classed('dimmed', false)
+      .attr('marker-end', d => `url(#arrow-${d.type})`)
+    if (linkLabelSelection) {
+      linkLabelSelection.classed('dimmed', false)
+    }
+  }
+}
+
 // 渲染力导向图
 function render() {
   if (!svgRef.value || !containerRef.value) return
@@ -873,13 +934,15 @@ function render() {
     .attr('marker-end', d => `url(#arrow-${d.type})`)
     .style('cursor', 'pointer')
     .on('mouseenter', (e, d) => {
-      // 激活状态下 hover 边,显示边详情
-      if (selectedNodeId.value) {
-        store.setHoverEdge(d)
-      }
+      // hover 边,显示边详情并高亮
+      isEdgeHovering = true
+      store.setHoverEdge(d)
+      applyEdgeHoverHighlight(d)
     })
     .on('mouseleave', () => {
+      isEdgeHovering = false
       store.clearHoverEdge()
+      clearEdgeHoverHighlight()
     })
     .on('click', (e, d) => {
       e.stopPropagation()
@@ -1031,6 +1094,9 @@ watch(() => store.expandedPanel, () => {
 watch([() => store.hoverPathNodes.size, () => store.hoverNodeId, () => store.hoverSource], () => {
   if (!nodeSelection || !linkSelection) return
 
+  // 边 hover 时不处理,避免覆盖边 hover 高亮
+  if (isEdgeHovering) return
+
   // 只处理来自推导图谱的 hover
   if (store.hoverSource === 'derivation') {
     applyDerivationHighlight()

+ 188 - 51
script/visualization/src/components/PostTreeView.vue

@@ -105,15 +105,15 @@
 
       <!-- 详情 -->
       <div v-if="showDetail" class="flex-1 flex flex-col min-h-0">
-        <div class="px-3 py-2 bg-base-300 text-base-content/60 shrink-0 flex items-center justify-between">
+        <div class="px-2 py-1.5 bg-base-300 text-base-content/60 shrink-0 flex items-center justify-between text-[11px]">
           <span>详情</span>
-          <label class="swap swap-flip text-[10px]">
+          <label class="swap swap-flip text-[9px]">
             <input type="checkbox" v-model="showRawData" />
             <span class="swap-on">JSON</span>
             <span class="swap-off">渲染</span>
           </label>
         </div>
-        <div class="flex-1 overflow-y-auto p-3 space-y-3">
+        <div class="flex-1 overflow-y-auto px-2 py-1.5 space-y-1.5">
           <!-- 原始JSON模式 -->
           <template v-if="showRawData && (displayNode || store.selectedEdge)">
             <div class="relative">
@@ -132,33 +132,33 @@
           <template v-else-if="!showRawData">
             <!-- 节点详情(hover 优先于 selected) -->
             <template v-if="displayNode">
-              <div class="flex items-center gap-2">
+              <div class="flex items-center gap-1.5">
                 <!-- hover 标识 -->
-                <span v-if="store.hoverNode" class="text-[10px] text-warning/60">[hover]</span>
+                <span v-if="store.hoverNode" class="text-[9px] text-warning/60">[hover]</span>
                 <!-- 节点样式:空心(帖子域)或实心(人设域) -->
                 <span
-                  class="w-2.5 h-2.5 shrink-0"
+                  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 }"
                 ></span>
-                <span class="text-primary font-medium truncate">{{ displayNode.name }}</span>
+                <span class="text-primary font-medium text-[11px] break-all">{{ displayNode.name }}</span>
               </div>
-              <div class="space-y-1.5 text-[11px]">
+              <div class="space-y-0.5 text-[10px]">
                 <template v-for="(value, key) in displayNode" :key="key">
                   <template v-if="!hiddenNodeFields.includes(key) && key !== 'name' && value !== null && value !== undefined && value !== ''">
-                    <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
-                      <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
-                      <span class="text-right break-all">{{ formatValue(value) }}</span>
+                    <div v-if="typeof value !== 'object'" class="flex gap-1">
+                      <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}:</span>
+                      <span class="break-all">{{ formatValue(value) }}</span>
                     </div>
-                    <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
-                      <span class="text-base-content/50">{{ formatKey(key) }}</span>
-                      <div class="pl-2 border-l border-base-content/20 space-y-1">
+                    <div v-else-if="Object.keys(value).length > 0" class="space-y-0.5">
+                      <span class="text-base-content/50">{{ formatKey(key) }}:</span>
+                      <div class="pl-2 border-l border-base-content/20 space-y-0.5 text-[9px]">
                         <template v-for="(v, k) in value" :key="k">
-                          <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
-                            <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
-                            <span class="text-right break-all">{{ formatValue(v) }}</span>
+                          <div v-if="v !== null && v !== undefined && v !== ''" class="flex gap-1">
+                            <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}:</span>
+                            <span class="break-all">{{ formatValue(v) }}</span>
                           </div>
                         </template>
                       </div>
@@ -167,70 +167,81 @@
                 </template>
               </div>
               <!-- 入边列表 -->
-              <div v-if="nodeInEdges.length > 0" class="mt-3 pt-2 border-t border-base-content/10">
+              <div v-if="nodeInEdges.length > 0" class="mt-2 pt-1.5 border-t border-base-content/10">
                 <div
-                  class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
+                  class="text-[9px] text-base-content/50 mb-0.5 cursor-pointer hover:text-primary"
                   @click="openEdgeListModal('in', nodeInEdges)"
                 >入边 ({{ nodeInEdges.length }}) ›</div>
-                <div class="space-y-1 max-h-24 overflow-y-auto">
+                <div class="space-y-0.5 max-h-20 overflow-y-auto">
                   <div
                     v-for="edge in nodeInEdges"
                     :key="`in-${edge.source}-${edge.type}`"
-                    class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
+                    class="flex items-center gap-1 text-[9px] px-0.5 py-0.5 rounded hover:bg-base-300 cursor-pointer"
                     @click="openEdgeModal(edge)"
                   >
-                    <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
+                    <span class="w-1.5 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
                     <span class="truncate flex-1">{{ getNodeName(edge.source) }}</span>
-                    <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
+                    <span class="text-base-content/40 shrink-0">{{ edge.score?.toFixed(2) || '-' }}</span>
                   </div>
                 </div>
               </div>
               <!-- 出边列表 -->
-              <div v-if="nodeOutEdges.length > 0" class="mt-2 pt-2 border-t border-base-content/10">
+              <div v-if="nodeOutEdges.length > 0" class="mt-1.5 pt-1.5 border-t border-base-content/10">
                 <div
-                  class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
+                  class="text-[9px] text-base-content/50 mb-0.5 cursor-pointer hover:text-primary"
                   @click="openEdgeListModal('out', nodeOutEdges)"
                 >出边 ({{ nodeOutEdges.length }}) ›</div>
-                <div class="space-y-1 max-h-24 overflow-y-auto">
+                <div class="space-y-0.5 max-h-20 overflow-y-auto">
                   <div
                     v-for="edge in nodeOutEdges"
                     :key="`out-${edge.target}-${edge.type}`"
-                    class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
+                    class="flex items-center gap-1 text-[9px] px-0.5 py-0.5 rounded hover:bg-base-300 cursor-pointer"
                     @click="openEdgeModal(edge)"
                   >
-                    <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
+                    <span class="w-1.5 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
                     <span class="truncate flex-1">{{ getNodeName(edge.target) }}</span>
-                    <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
+                    <span class="text-base-content/40 shrink-0">{{ edge.score?.toFixed(2) || '-' }}</span>
                   </div>
                 </div>
               </div>
             </template>
             <!-- 边详情(hover 优先于 selected) -->
             <template v-else-if="displayEdge">
-              <div class="flex items-center gap-2">
-                <span class="w-4 h-0.5 shrink-0" :style="{ backgroundColor: displayEdgeColor }"></span>
-                <span class="text-secondary font-medium">{{ displayEdge.type }} 边</span>
+              <div class="flex items-center gap-1.5 mb-1">
+                <span class="w-3 h-0.5 shrink-0" :style="{ backgroundColor: displayEdgeColor }"></span>
+                <span class="text-secondary font-medium text-[11px]">{{ displayEdge.type }}</span>
+                <span v-if="displayEdge.score !== undefined" class="text-primary text-[10px]">{{ displayEdge.score.toFixed(2) }}</span>
               </div>
-              <div class="space-y-1.5 text-[11px]">
-                <template v-for="(value, key) in displayEdge" :key="key">
-                  <template v-if="key !== 'index' && value !== null && value !== undefined && value !== ''">
-                    <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
-                      <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
-                      <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
-                    </div>
-                    <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
-                      <span class="text-base-content/50">{{ formatKey(key) }}</span>
-                      <div class="pl-2 border-l border-base-content/20 space-y-1">
-                        <template v-for="(v, k) in value" :key="k">
-                          <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
-                            <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
-                            <span class="text-right break-all">{{ formatValue(v) }}</span>
-                          </div>
-                        </template>
-                      </div>
+              <div class="space-y-0.5 text-[10px]">
+                <!-- 边的 detail 内容 -->
+                <template v-if="displayEdge.detail && Object.keys(displayEdge.detail).length > 0">
+                  <template v-for="(v, k) in displayEdge.detail" :key="k">
+                    <div v-if="v !== null && v !== undefined && v !== ''" class="flex gap-1">
+                      <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}:</span>
+                      <span class="break-all">{{ formatValue(v) }}</span>
                     </div>
                   </template>
+                  <div class="border-t border-base-content/10 my-1"></div>
                 </template>
+                <!-- 源节点和目标节点 -->
+                <div class="text-[9px] space-y-0.5">
+                  <div class="flex items-center gap-1">
+                    <span class="text-base-content/40">源:</span>
+                    <span
+                      class="w-1.5 h-1.5 shrink-0 rounded-full"
+                      :style="{ backgroundColor: getNodeColor(getEdgeSourceNode) }"
+                    ></span>
+                    <span class="break-all">{{ getEdgeSourceNode?.name || displayEdge.source }}</span>
+                  </div>
+                  <div class="flex items-center gap-1">
+                    <span class="text-base-content/40">目标:</span>
+                    <span
+                      class="w-1.5 h-1.5 shrink-0 rounded-full"
+                      :style="{ backgroundColor: getNodeColor(getEdgeTargetNode) }"
+                    ></span>
+                    <span class="break-all">{{ getEdgeTargetNode?.name || displayEdge.target }}</span>
+                  </div>
+                </div>
               </div>
             </template>
             <!-- 无选中 -->
@@ -479,8 +490,12 @@ function getTargetNodeColor(edge) {
   return '#888'
 }
 
-// 显示的节点(hover 优先于 selected)
-const displayNode = computed(() => store.hoverNode || store.selectedNode)
+// 显示的节点(hover 优先于 selected,但边 hover 时不显示节点)
+const displayNode = computed(() => {
+  // 如果正在 hover 边,不显示节点详情
+  if (store.hoverEdgeData) return null
+  return store.hoverNode || store.selectedNode
+})
 
 // 显示节点的样式
 const displayNodeStyle = computed(() => {
@@ -514,6 +529,39 @@ const displayEdgeColor = computed(() => {
   return edgeTypeColors[displayEdge.value.type] || '#888'
 })
 
+// 边的源节点
+const getEdgeSourceNode = computed(() => {
+  if (!displayEdge.value) return null
+  const sourceId = typeof displayEdge.value.source === 'object'
+    ? displayEdge.value.source.id
+    : displayEdge.value.source
+  return store.getNode(sourceId)
+})
+
+// 边的目标节点
+const getEdgeTargetNode = computed(() => {
+  if (!displayEdge.value) return null
+  const targetId = typeof displayEdge.value.target === 'object'
+    ? displayEdge.value.target.id
+    : displayEdge.value.target
+  return store.getNode(targetId)
+})
+
+// 获取节点颜色
+const dimensionColors = {
+  '灵感点': '#f39c12',
+  '目的点': '#3498db',
+  '关键点': '#9b59b6'
+}
+
+function getNodeColor(node) {
+  if (!node) return '#888'
+  if (node.dimension && dimensionColors[node.dimension]) {
+    return dimensionColors[node.dimension]
+  }
+  return '#888'
+}
+
 // 节点的入边列表(按分数降序)
 const nodeInEdges = computed(() => {
   if (!displayNode.value) return []
@@ -583,6 +631,9 @@ let mainG = null
 let treeWidth = 0
 let treeHeight = 0
 
+// 是否正在 hover 边(防止 watch 覆盖边 hover 高亮)
+let isEdgeHovering = false
+
 // 选择帖子
 function selectPost(index) {
   selectedPostIdx.value = index
@@ -842,6 +893,22 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
     .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
     .style('cursor', 'pointer')
+    .on('mouseenter', (e, d) => {
+      // hover 边,显示边详情并高亮
+      isEdgeHovering = true
+      store.setHoverEdge({
+        source: d.source,
+        target: d.target,
+        type: '匹配',
+        score: d.score
+      })
+      applyEdgeHoverHighlight(d.source, d.target)
+    })
+    .on('mouseleave', () => {
+      isEdgeHovering = false
+      store.clearHoverEdge()
+      clearEdgeHoverHighlight()
+    })
     .on('click', (e, d) => {
       e.stopPropagation()
       store.selectEdge({
@@ -1099,6 +1166,23 @@ function renderWalkedLayer() {
     .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
     .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
     .style('cursor', 'pointer')
+    .on('mouseenter', (e, d) => {
+      // hover 边,显示边详情并高亮
+      isEdgeHovering = true
+      store.setHoverEdge({
+        source: d.source,
+        target: d.target,
+        type: d.type,
+        score: d.score,
+        detail: d.detail
+      })
+      applyEdgeHoverHighlight(d.source, d.target)
+    })
+    .on('mouseleave', () => {
+      isEdgeHovering = false
+      store.clearHoverEdge()
+      clearEdgeHoverHighlight()
+    })
     .on('click', (e, d) => {
       e.stopPropagation()
       store.selectEdge({
@@ -1530,6 +1614,55 @@ function zoomToEdge(sourceId, targetId) {
   )
 }
 
+// 边 hover 高亮(只高亮当前边和两个端点)
+function applyEdgeHoverHighlight(sourceId, targetId) {
+  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')
+
+  // 节点:只高亮两个端点,清除其他状态
+  allNodes
+    .classed('dimmed', function(d) {
+      const nodeId = d.data?.id || d.id
+      return nodeId !== sourceId && nodeId !== targetId
+    })
+    .classed('selected', false)
+    .classed('locked-path', false)
+    .classed('highlighted', false)
+
+  // 边:只高亮当前边,清除 highlighted 状态
+  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 isCurrentEdge = srcId === sourceId && tgtId === targetId
+    d3.select(this)
+      .classed('dimmed', !isCurrentEdge)
+      .classed('highlighted', isCurrentEdge)
+      .classed('locked-path', false)
+  })
+
+  // 标签
+  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 isCurrentEdge = srcId === sourceId && tgtId === targetId
+    d3.select(this)
+      .classed('dimmed', !isCurrentEdge)
+      .classed('locked-path', false)
+  })
+}
+
+// 清除边 hover 高亮
+function clearEdgeHoverHighlight() {
+  if (!svgRef.value) return
+
+  // 恢复正常高亮状态
+  updateHighlight()
+}
+
 // 更新高亮/置灰状态
 function updateHighlight() {
   // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
@@ -1575,6 +1708,10 @@ watch(() => store.highlightedNodeIds.size, updateHighlight)
 // 监听 hover 状态变化(用于左右联动)
 watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   if (!svgRef.value) return
+
+  // 边 hover 时不处理,避免覆盖边 hover 高亮
+  if (isEdgeHovering) return
+
   const svg = d3.select(svgRef.value)
 
   const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')