Explorar o código

feat: 优化人设图谱可视化交互和游走配置

- 添加分步游走配置(全选/清空/默认按钮)
- 修复清空边类型仍显示边的bug
- 添加树节点搜索和自动补全功能
- 添加节点颜色/形状/边类型图例
- 非高亮节点置灰显示
- 合并"分类共现_点内"到"分类共现"

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui hai 3 días
pai
achega
a49e093465

+ 5 - 5
script/data_processing/build_persona_graph.py

@@ -34,7 +34,7 @@
         "{source}|{type}|{target}": {
             "source": "源节点ID",
             "target": "目标节点ID",
-            "type": "属于|包含|标签共现|分类共现|分类共现_点内",
+            "type": "属于|包含|标签共现|分类共现|分类共现",
             "score": 0.5,
             "detail": { ... }
         }
@@ -82,7 +82,7 @@
 - 包含:         父节点 -> 子节点(层级关系)
 - 标签共现:     标签 <-> 标签(同一帖子出现)
 - 分类共现:     分类 <-> 分类(跨维度共现)
-- 分类共现_点内: 分类 <-> 分类(点内组合共现)
+- 分类共现: 分类 <-> 分类(点内组合共现)
 
 ================================================================================
 图游走函数:
@@ -507,7 +507,7 @@ def extract_intra_category_cooccur_edges(intra_data: Dict) -> Dict[str, Dict]:
                     if cat1_id > cat2_id:
                         cat1_id, cat2_id = cat2_id, cat1_id
 
-                    edge_id = build_edge_id(cat1_id, "分类共现_点内", cat2_id)
+                    edge_id = build_edge_id(cat1_id, "分类共现", cat2_id)
 
                     if edge_id in edges:
                         # 累加
@@ -517,7 +517,7 @@ def extract_intra_category_cooccur_edges(intra_data: Dict) -> Dict[str, Dict]:
                         edges[edge_id] = create_edge(
                             source=cat1_id,
                             target=cat2_id,
-                            edge_type="分类共现_点内",
+                            edge_type="分类共现",
                             score=point_count,  # 先用点数作为 score,后续可归一化
                             detail={
                                 "pointCount": point_count,
@@ -945,7 +945,7 @@ def main():
     print("\n提取分类共现边(点内):")
     intra_category_edges = extract_intra_category_cooccur_edges(intra_associations_data)
     all_edges.update(intra_category_edges)
-    print(f"  分类共现_点内边: {len(intra_category_edges)}")
+    print(f"  分类共现边: {len(intra_category_edges)}")
 
     # 标签共现边
     print("\n提取标签共现边:")

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

@@ -3,7 +3,13 @@
     <!-- 顶部栏 -->
     <header class="navbar bg-base-200 min-h-0 px-4 py-2 shrink-0">
       <h1 class="text-sm font-medium text-primary">人设图谱</h1>
-      <div class="flex gap-4 text-xs text-base-content/60 ml-6">
+
+      <!-- 节点颜色图例 -->
+      <div class="flex gap-3 text-xs text-base-content/60 ml-6 border-l border-base-content/20 pl-4">
+        <span class="text-base-content font-medium">节点颜色:</span>
+        <div class="flex items-center gap-1">
+          <span class="w-2.5 h-2.5 rounded-full bg-dim-persona"></span>人设
+        </div>
         <div class="flex items-center gap-1">
           <span class="w-2.5 h-2.5 rounded-full bg-dim-inspiration"></span>灵感点
         </div>
@@ -13,9 +19,23 @@
         <div class="flex items-center gap-1">
           <span class="w-2.5 h-2.5 rounded-full bg-dim-key"></span>关键点
         </div>
+      </div>
+
+      <!-- 节点形状图例 -->
+      <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
+        <span class="text-base-content font-medium">节点形状:</span>
         <div class="flex items-center gap-1">○ 标签</div>
         <div class="flex items-center gap-1">□ 分类</div>
       </div>
+
+      <!-- 边类型图例 -->
+      <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
+        <span class="text-base-content font-medium">边:</span>
+        <div v-for="(color, type) in edgeTypesWithColors" :key="type" class="flex items-center gap-1">
+          <span class="w-4 h-0.5" :style="{ backgroundColor: color }"></span>
+          <span>{{ type }}</span>
+        </div>
+      </div>
     </header>
 
     <!-- 主内容区 -->
@@ -30,7 +50,28 @@
 </template>
 
 <script setup>
+import { computed } from 'vue'
 import TreeView from './components/TreeView.vue'
 import GraphView from './components/GraphView.vue'
 import DetailPanel from './components/DetailPanel.vue'
+import { useGraphStore } from './stores/graph'
+
+const store = useGraphStore()
+
+// 边类型颜色调色板
+const edgeColorPalette = ['#9b59b6', '#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#1abc9c']
+
+// 从数据中动态获取边类型及对应颜色
+const edgeTypesWithColors = computed(() => {
+  const types = new Set()
+  const edges = store.graphData.edges || {}
+  for (const edge of Object.values(edges)) {
+    if (edge.type) types.add(edge.type)
+  }
+  const result = {}
+  Array.from(types).forEach((t, i) => {
+    result[t] = edgeColorPalette[i % edgeColorPalette.length]
+  })
+  return result
+})
 </script>

+ 94 - 110
script/visualization/src/components/GraphView.vue

@@ -19,39 +19,15 @@
         <span class="w-6 text-center">{{ walkSteps }}</span>
       </div>
 
-      <!-- 配置模式切换 -->
-      <div class="flex items-center gap-3">
-        <span class="text-base-content/60 w-16">配置模式:</span>
-        <label class="flex items-center gap-1 cursor-pointer">
-          <input type="radio" v-model="configMode" value="global" class="radio radio-xs radio-primary" />
-          <span>整体设置</span>
-        </label>
-        <label class="flex items-center gap-1 cursor-pointer">
-          <input type="radio" v-model="configMode" value="step" class="radio radio-xs radio-primary" />
-          <span>分步设置</span>
-        </label>
-      </div>
-
-      <!-- 整体设置 -->
-      <div v-if="configMode === 'global'" class="pl-4 space-y-2 border-l-2 border-primary/30">
-        <div class="flex items-center gap-2 flex-wrap">
-          <span class="text-base-content/60 w-14">边类型:</span>
-          <label v-for="et in allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
-            <input type="checkbox" v-model="globalEdgeTypes" :value="et" class="checkbox checkbox-xs" />
-            <span :style="{ color: edgeColors[et] }">{{ et }}</span>
-          </label>
-        </div>
-        <div class="flex items-center gap-2">
-          <span class="text-base-content/60 w-14">最小分:</span>
-          <input type="range" :min="0" :max="1" :step="0.1" v-model.number="globalMinScore" class="range range-xs flex-1" />
-          <span class="w-8 text-center">{{ globalMinScore.toFixed(1) }}</span>
-        </div>
-      </div>
-
       <!-- 分步设置 -->
-      <div v-else class="space-y-2">
+      <div class="space-y-2">
         <div v-for="step in walkSteps" :key="step" class="pl-4 space-y-1 border-l-2 border-secondary/30">
-          <div class="font-medium text-secondary">第 {{ step }} 步</div>
+          <div class="flex items-center gap-2">
+            <span class="font-medium text-secondary">第 {{ step }} 步</span>
+            <button @click="selectAllEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">全选</button>
+            <button @click="clearEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">清空</button>
+            <button @click="resetEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">默认</button>
+          </div>
           <div class="flex items-center gap-2 flex-wrap">
             <span class="text-base-content/60 w-14">边类型:</span>
             <label v-for="et in allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
@@ -95,36 +71,67 @@ const dimColors = {
   '关键点': '#9b59b6'
 }
 
-// 边类型颜色
-const edgeColors = {
-  '属于': '#9b59b6',
-  '包含': '#3498db',
-  '分类共现': '#2ecc71',
-  '分类共现_点内': '#27ae60',
-  '标签共现': '#f39c12'
-}
+// 边类型颜色(动态分配)
+const edgeColorPalette = ['#9b59b6', '#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#1abc9c']
+const edgeColors = computed(() => {
+  const colors = {}
+  allEdgeTypes.value.forEach((et, i) => {
+    colors[et] = edgeColorPalette[i % edgeColorPalette.length]
+  })
+  return colors
+})
 
-// 所有边类型
-const allEdgeTypes = ['属于', '包含', '标签共现', '分类共现', '分类共现_点内']
+// 从数据中动态获取所有边类型
+const allEdgeTypes = computed(() => {
+  const types = new Set()
+  const edges = store.graphData.edges || {}
+  for (const edge of Object.values(edges)) {
+    if (edge.type) types.add(edge.type)
+  }
+  return Array.from(types)
+})
 
 // 游走配置
 const showConfig = ref(false)
 const walkSteps = ref(2)
-const configMode = ref('global')  // 'global' | 'step'
-
-// 整体设置
-const globalEdgeTypes = ref([...allEdgeTypes])
-const globalMinScore = ref(0)
 
 // 分步设置(最多5步)
+// 默认:第1步全选,第2步及以后只选"属于"
 const stepConfigs = reactive([
-  { edgeTypes: [...allEdgeTypes], minScore: 0 },
-  { edgeTypes: [...allEdgeTypes], minScore: 0 },
-  { edgeTypes: [...allEdgeTypes], minScore: 0 },
-  { edgeTypes: [...allEdgeTypes], minScore: 0 },
-  { edgeTypes: [...allEdgeTypes], minScore: 0 }
+  { edgeTypes: [], minScore: 0 },
+  { edgeTypes: ['属于'], minScore: 0 },
+  { edgeTypes: ['属于'], minScore: 0 },
+  { edgeTypes: ['属于'], minScore: 0 },
+  { edgeTypes: ['属于'], minScore: 0 }
 ])
 
+// 监听边类型变化,初始化第1步为全选
+watch(allEdgeTypes, (types) => {
+  if (stepConfigs[0].edgeTypes.length === 0) {
+    stepConfigs[0].edgeTypes = [...types]
+  }
+}, { immediate: true })
+
+// 全选边类型
+function selectAllEdgeTypes(stepIndex) {
+  stepConfigs[stepIndex].edgeTypes = [...allEdgeTypes.value]
+}
+
+// 清空边类型
+function clearEdgeTypes(stepIndex) {
+  stepConfigs[stepIndex].edgeTypes = []
+}
+
+// 重置为默认边类型(第1步全选,第2步及以后只选"属于")
+function resetEdgeTypes(stepIndex) {
+  if (stepIndex === 0) {
+    stepConfigs[stepIndex].edgeTypes = [...allEdgeTypes.value]
+  } else {
+    stepConfigs[stepIndex].edgeTypes = ['属于']
+  }
+  stepConfigs[stepIndex].minScore = 0
+}
+
 // 游走时记录的边
 let walkedEdges = []
 
@@ -137,9 +144,7 @@ function executeWalk() {
   walkedEdges = []  // 清空之前的边记录
 
   for (let step = 0; step < walkSteps.value; step++) {
-    const config = configMode.value === 'global'
-      ? { edgeTypes: globalEdgeTypes.value, minScore: globalMinScore.value }
-      : stepConfigs[step]
+    const config = stepConfigs[step]
 
     const nextFrontier = new Set()
 
@@ -219,34 +224,27 @@ function renderGraph() {
 
   if (!centerNode) return
 
-  // 判断是否有游走高亮结果
-  const hasWalkResult = store.highlightedNodeIds.size > 1
-
   // 准备节点和边数据
   const nodes = []
   const links = []
   const nodeSet = new Set()
-  const linkSet = new Set()
-
-  if (hasWalkResult) {
-    // 游走模式:显示所有高亮节点
-    for (const nodeId of store.highlightedNodeIds) {
-      const nodeData = store.getNode(nodeId)
-      if (nodeData) {
-        nodes.push({
-          id: nodeId,
-          ...nodeData,
-          isCenter: nodeId === centerNodeId,
-          isHighlighted: true
-        })
-        nodeSet.add(nodeId)
-      }
+
+  // 始终使用游走模式:显示所有高亮节点和走过的边
+  for (const nodeId of store.highlightedNodeIds) {
+    const nodeData = store.getNode(nodeId)
+    if (nodeData) {
+      nodes.push({
+        id: nodeId,
+        ...nodeData,
+        isCenter: nodeId === centerNodeId,
+        isHighlighted: store.highlightedNodeIds.size > 1
+      })
+      nodeSet.add(nodeId)
     }
+  }
 
-    // 使用游走时记录的边(只显示实际走过的路径)
-    links.push(...walkedEdges)
-  } else {
-    // 普通模式:显示选中节点的直接邻居
+  // 如果高亮集合为空(不应该发生),至少显示中心节点
+  if (nodes.length === 0) {
     nodes.push({
       id: centerNodeId,
       ...centerNode,
@@ -254,28 +252,11 @@ function renderGraph() {
       isHighlighted: false
     })
     nodeSet.add(centerNodeId)
-
-    const neighbors = store.getNeighbors(centerNodeId)
-    for (const n of neighbors) {
-      const nodeData = store.getNode(n.nodeId)
-      if (nodeData && !nodeSet.has(n.nodeId)) {
-        nodeSet.add(n.nodeId)
-        nodes.push({
-          id: n.nodeId,
-          ...nodeData,
-          isCenter: false,
-          isHighlighted: false
-        })
-        links.push({
-          source: n.direction === 'out' ? centerNodeId : n.nodeId,
-          target: n.direction === 'out' ? n.nodeId : centerNodeId,
-          type: n.edgeType,
-          score: n.score
-        })
-      }
-    }
   }
 
+  // 使用游走时记录的边(只显示实际走过的路径)
+  links.push(...walkedEdges)
+
   const g = svg.append('g')
 
   // 缩放
@@ -297,18 +278,9 @@ function renderGraph() {
     .data(links)
     .join('line')
     .attr('class', 'graph-link')
-    .attr('stroke', d => edgeColors[d.type] || '#666')
+    .attr('stroke', d => edgeColors.value[d.type] || '#666')
     .attr('stroke-width', 1.5)
 
-  // 边标签
-  const linkLabel = g.append('g')
-    .selectAll('text')
-    .data(links)
-    .join('text')
-    .attr('class', 'graph-link-label')
-    .attr('text-anchor', 'middle')
-    .text(d => d.type)
-
   // 节点组
   const node = g.append('g')
     .selectAll('g')
@@ -384,14 +356,19 @@ function renderGraph() {
       .attr('x2', d => d.target.x)
       .attr('y2', d => d.target.y)
 
-    linkLabel
-      .attr('x', d => (d.source.x + d.target.x) / 2)
-      .attr('y', d => (d.source.y + d.target.y) / 2)
-
     node.attr('transform', d => `translate(${d.x},${d.y})`)
   })
 }
 
+// 点击空白取消激活
+function handleSvgClick(event) {
+  if (event.target.tagName === 'svg') {
+    store.clearSelection()
+    walkedEdges = []
+    renderGraph()
+  }
+}
+
 watch(() => store.selectedNodeId, (nodeId) => {
   if (nodeId) {
     executeWalk()  // 点击节点自动执行游走
@@ -400,6 +377,13 @@ watch(() => store.selectedNodeId, (nodeId) => {
   }
 })
 
+// 监听配置变化,及时重新游走
+watch([walkSteps, stepConfigs], () => {
+  if (store.selectedNodeId) {
+    executeWalk()
+  }
+}, { deep: true })
+
 onMounted(() => {
   renderGraph()
 })

+ 129 - 12
script/visualization/src/components/TreeView.vue

@@ -3,23 +3,66 @@
     <!-- 头部 -->
     <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
       <span>人设树</span>
-      <div class="flex gap-2">
-        <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
-          已高亮 {{ store.highlightedNodeIds.size }} 个节点
-        </span>
-        <button @click="clearHighlight" class="btn btn-ghost btn-xs">清除高亮</button>
+      <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
+        已高亮 {{ store.highlightedNodeIds.size }} 个节点
+      </span>
+    </div>
+
+    <!-- 搜索框 -->
+    <div class="px-4 py-2 bg-base-200 border-b border-base-300 relative">
+      <input
+        type="text"
+        v-model="searchQuery"
+        @input="onSearchInput"
+        @focus="showSuggestions = true"
+        @keydown.down.prevent="navigateSuggestion(1)"
+        @keydown.up.prevent="navigateSuggestion(-1)"
+        @keydown.enter.prevent="selectSuggestion"
+        @keydown.escape="showSuggestions = false"
+        placeholder="搜索节点..."
+        class="input input-xs input-bordered w-full"
+      />
+      <!-- 自动补全下拉 -->
+      <div
+        v-show="showSuggestions && filteredNodes.length > 0"
+        class="absolute left-4 right-4 top-full mt-1 bg-base-100 border border-base-300 rounded shadow-lg max-h-48 overflow-y-auto z-50"
+      >
+        <div
+          v-for="(node, index) in filteredNodes"
+          :key="node.id"
+          @click="selectNode(node)"
+          @mouseenter="suggestionIndex = index"
+          :class="[
+            'px-3 py-1.5 cursor-pointer text-xs flex items-center gap-2',
+            index === suggestionIndex ? 'bg-primary/20' : 'hover:bg-base-200'
+          ]"
+        >
+          <!-- 标签用圆形,分类用方形 -->
+          <span
+            v-if="node.type === '分类'"
+            class="w-2 h-2 rounded-sm"
+            :style="{ backgroundColor: getNodeColorById(node) }"
+          ></span>
+          <span
+            v-else
+            class="w-2 h-2 rounded-full"
+            :style="{ backgroundColor: getNodeColorById(node) }"
+          ></span>
+          <span>{{ node.name }}</span>
+          <span class="text-base-content/40 text-xs">{{ node.dimension || node.type }}</span>
+        </div>
       </div>
     </div>
 
     <!-- SVG 容器 -->
     <div ref="containerRef" class="flex-1 overflow-auto bg-base-100">
-      <svg ref="svgRef" class="block"></svg>
+      <svg ref="svgRef" class="block" @click="handleSvgClick"></svg>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted, watch } from 'vue'
+import { ref, computed, onMounted, watch } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 
@@ -28,6 +71,65 @@ const store = useGraphStore()
 const containerRef = ref(null)
 const svgRef = ref(null)
 
+// 搜索相关
+const searchQuery = ref('')
+const showSuggestions = ref(false)
+const suggestionIndex = ref(0)
+
+// 获取所有节点列表
+const allNodes = computed(() => {
+  const nodes = store.graphData.nodes || {}
+  return Object.entries(nodes).map(([id, node]) => ({
+    id,
+    ...node
+  }))
+})
+
+// 过滤后的节点(搜索结果)
+const filteredNodes = computed(() => {
+  if (!searchQuery.value.trim()) return []
+  const query = searchQuery.value.toLowerCase()
+  return allNodes.value
+    .filter(node => node.name && node.name.toLowerCase().includes(query))
+    .slice(0, 20) // 最多显示20个结果
+})
+
+// 搜索输入
+function onSearchInput() {
+  showSuggestions.value = true
+  suggestionIndex.value = 0
+}
+
+// 键盘导航
+function navigateSuggestion(delta) {
+  const len = filteredNodes.value.length
+  if (len === 0) return
+  suggestionIndex.value = (suggestionIndex.value + delta + len) % len
+}
+
+// 选中建议项
+function selectSuggestion() {
+  if (filteredNodes.value.length > 0) {
+    selectNode(filteredNodes.value[suggestionIndex.value])
+  }
+}
+
+// 选中节点(激活)
+function selectNode(node) {
+  store.selectedNodeId = node.id
+  store.highlightedNodeIds = new Set([node.id])
+  searchQuery.value = ''
+  showSuggestions.value = false
+  updateSelection()
+  scrollToNode(node.id)
+}
+
+// 根据节点数据获取颜色
+function getNodeColorById(node) {
+  if (node.type === '人设') return dimColors['人设']
+  return dimColors[node.dimension] || '#888'
+}
+
 // 维度颜色映射
 const dimColors = {
   '人设': '#e94560',
@@ -205,26 +307,38 @@ function scrollToNode(nodeId) {
 // 更新选中/高亮状态
 function updateSelection() {
   const svg = d3.select(svgRef.value)
+  const hasHighlight = store.highlightedNodeIds.size > 0
 
   svg.selectAll('.tree-node')
     .classed('selected', d => store.selectedNodeId === d.data.id)
     .classed('highlighted', d => store.highlightedNodeIds.has(d.data.id))
+    .classed('dimmed', d => hasHighlight && !store.highlightedNodeIds.has(d.data.id))
 
   svg.selectAll('.tree-link')
     .classed('highlighted', d => {
-      // 只有当边的两端都在高亮集合中时才高亮边
       return store.highlightedNodeIds.has(d.source.data.id) &&
              store.highlightedNodeIds.has(d.target.data.id)
     })
+    .classed('dimmed', d => {
+      return hasHighlight && !(store.highlightedNodeIds.has(d.source.data.id) &&
+             store.highlightedNodeIds.has(d.target.data.id))
+    })
 }
 
 // 清除高亮
 function clearHighlight() {
-  store.highlightedNodeIds = new Set()
-  store.selectedNodeId = null
+  store.clearSelection()
   updateSelection()
 }
 
+// 点击空白取消激活
+function handleSvgClick(event) {
+  // 只有点击 SVG 背景才清除
+  if (event.target.tagName === 'svg') {
+    clearHighlight()
+  }
+}
+
 // 滚动到根节点
 function scrollToRoot() {
   if (!currentRoot) return
@@ -241,13 +355,16 @@ watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
   if (nodeId && nodeId !== oldNodeId) {
     updateSelection()
     scrollToNode(nodeId)
+  } else if (!nodeId) {
+    // 清除选中时也要更新
+    updateSelection()
   }
 })
 
 // 监听高亮变化(从 GraphView 游走结果同步)
-watch(() => store.highlightedNodeIds, () => {
+watch(() => store.highlightedNodeIds.size, () => {
   updateSelection()
-}, { deep: true })
+})
 
 onMounted(() => {
   renderTree()

+ 14 - 0
script/visualization/src/style.css

@@ -53,6 +53,20 @@
     stroke-width: 2;
   }
 
+  /* 有高亮时,非高亮节点置灰 */
+  .tree-node.dimmed circle,
+  .tree-node.dimmed rect {
+    opacity: 0.2;
+  }
+
+  .tree-node.dimmed text {
+    opacity: 0.2;
+  }
+
+  .tree-link.dimmed {
+    stroke-opacity: 0.1;
+  }
+
   .tree-node text {
     @apply text-xs;
     fill: oklch(var(--bc));