GraphView.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. <template>
  2. <div class="flex flex-col h-full">
  3. <!-- 头部 -->
  4. <div class="flex items-center gap-3 px-4 py-2 bg-base-300 text-xs text-base-content/60 shrink-0">
  5. <span>相关图</span>
  6. <span v-if="store.selectedNodeId" class="text-primary font-medium">{{ currentNodeName }}</span>
  7. <div class="flex-1"></div>
  8. <button v-if="store.selectedNodeId" @click="showConfig = !showConfig" class="btn btn-ghost btn-xs">
  9. {{ showConfig ? '隐藏筛选' : '筛选' }}
  10. </button>
  11. <template v-if="showExpand && store.selectedNodeId">
  12. <button
  13. v-if="store.expandedPanel !== 'graph'"
  14. @click="store.expandPanel('graph')"
  15. class="btn btn-ghost btn-xs"
  16. title="放大"
  17. >⤢</button>
  18. <button
  19. v-if="store.expandedPanel !== 'default'"
  20. @click="store.resetLayout()"
  21. class="btn btn-ghost btn-xs"
  22. title="恢复"
  23. >⊡</button>
  24. </template>
  25. </div>
  26. <!-- 人设节点筛选配置 -->
  27. <div v-show="showConfig && isPersonaWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
  28. <!-- 步数设置 -->
  29. <div class="flex items-center gap-2">
  30. <span class="text-base-content/60 w-16">筛选步数:</span>
  31. <input type="number" :min="1" :max="5" v-model.number="store.walkSteps" class="input input-xs input-bordered w-16 text-center" />
  32. </div>
  33. <!-- 分步设置 -->
  34. <div class="space-y-2">
  35. <div v-for="step in store.walkSteps" :key="step" class="pl-4 space-y-1 border-l-2 border-secondary/30">
  36. <div class="flex items-center gap-2">
  37. <span class="font-medium text-secondary">第 {{ step }} 步</span>
  38. <button @click="selectAllEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">全选</button>
  39. <button @click="clearEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">清空</button>
  40. <button @click="resetEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">默认</button>
  41. </div>
  42. <div class="flex items-center gap-2 flex-wrap">
  43. <span class="text-base-content/60 w-14">边类型:</span>
  44. <label v-for="et in store.allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
  45. <input type="checkbox" v-model="store.stepConfigs[step-1].edgeTypes" :value="et" class="checkbox checkbox-xs" />
  46. <span :style="{ color: edgeTypeColors[et] }">{{ et }}</span>
  47. </label>
  48. </div>
  49. <div class="flex items-center gap-2">
  50. <span class="text-base-content/60 w-14">最小分:</span>
  51. <input type="number" :min="0" :max="1" :step="0.1" v-model.number="store.stepConfigs[step-1].minScore" class="input input-xs input-bordered w-16 text-center" />
  52. </div>
  53. </div>
  54. </div>
  55. </div>
  56. <!-- 帖子标签节点筛选配置 -->
  57. <div v-show="showConfig && isPostWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-2">
  58. <div class="flex items-center gap-2">
  59. <span class="text-base-content/60 w-20">最大步数:</span>
  60. <input type="number" :min="2" :max="10" v-model.number="store.postWalkConfig.maxSteps" class="input input-xs input-bordered w-16 text-center" />
  61. </div>
  62. <div class="flex items-center gap-2">
  63. <span class="text-base-content/60 w-20">最后步分数:</span>
  64. <input type="number" :min="0" :max="1" :step="0.1" v-model.number="store.postWalkConfig.lastStepMinScore" class="input input-xs input-bordered w-16 text-center" />
  65. </div>
  66. <div class="flex items-center gap-2">
  67. <span class="text-base-content/60 w-20">中间边类型:</span>
  68. <button @click="selectAllMiddleEdgeTypes" class="btn btn-ghost btn-xs text-base-content/50">全选</button>
  69. <button @click="clearMiddleEdgeTypes" class="btn btn-ghost btn-xs text-base-content/50">清空</button>
  70. <button @click="resetMiddleEdgeTypes" class="btn btn-ghost btn-xs text-base-content/50">默认</button>
  71. </div>
  72. <div class="flex items-center gap-2 flex-wrap pl-20">
  73. <label v-for="t in middleEdgeTypeOptions" :key="t" class="flex items-center gap-1 cursor-pointer">
  74. <input type="checkbox" :value="t" v-model="store.postWalkConfig.middleEdgeTypes" class="checkbox checkbox-xs checkbox-primary" />
  75. <span>{{ t }}</span>
  76. </label>
  77. </div>
  78. <div class="flex items-center gap-2">
  79. <span class="text-base-content/60 w-20">中间步分数:</span>
  80. <input type="number" :min="0" :max="1" :step="0.1" v-model.number="store.postWalkConfig.middleMinScore" class="input input-xs input-bordered w-16 text-center" />
  81. </div>
  82. <div class="flex items-center gap-2 text-base-content/50">
  83. <span>路径: 帖子标签</span>
  84. <span class="text-primary">--{{ store.postWalkConfig.firstEdgeType }}--></span>
  85. <span>人设图谱</span>
  86. <span class="text-primary">--{{ store.postWalkConfig.lastEdgeType }}--></span>
  87. <span>其他标签</span>
  88. </div>
  89. <div v-if="store.postWalkedPaths.length > 0" class="text-success">
  90. 找到 {{ store.postWalkedPaths.length }} 条路径
  91. </div>
  92. </div>
  93. <!-- SVG 容器 -->
  94. <div ref="containerRef" class="flex-1 relative overflow-hidden">
  95. <svg ref="svgRef" class="w-full h-full transition-opacity duration-200"></svg>
  96. </div>
  97. </div>
  98. </template>
  99. <script setup>
  100. import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
  101. import * as d3 from 'd3'
  102. import { useGraphStore } from '../stores/graph'
  103. import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
  104. import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
  105. import { applyHighlight, findPath, applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
  106. const props = defineProps({
  107. collapsed: { type: Boolean, default: false },
  108. showExpand: { type: Boolean, default: false }
  109. })
  110. const store = useGraphStore()
  111. const containerRef = ref(null)
  112. const svgRef = ref(null)
  113. const showConfig = ref(false)
  114. // 中间步骤可选的边类型(排除匹配边)
  115. const middleEdgeTypeOptions = computed(() => {
  116. return store.allEdgeTypes.filter(t => t !== '匹配')
  117. })
  118. // 中间边类型操作
  119. function selectAllMiddleEdgeTypes() {
  120. store.postWalkConfig.middleEdgeTypes = [...middleEdgeTypeOptions.value]
  121. }
  122. function clearMiddleEdgeTypes() {
  123. store.postWalkConfig.middleEdgeTypes = []
  124. }
  125. function resetMiddleEdgeTypes() {
  126. store.postWalkConfig.middleEdgeTypes = ['属于', '包含', '分类共现']
  127. store.postWalkConfig.middleMinScore = 0.3
  128. }
  129. let simulation = null
  130. // 游走配置操作(直接操作 store)
  131. function selectAllEdgeTypes(stepIndex) {
  132. store.stepConfigs[stepIndex].edgeTypes = [...store.allEdgeTypes]
  133. }
  134. function clearEdgeTypes(stepIndex) {
  135. store.stepConfigs[stepIndex].edgeTypes = []
  136. }
  137. function resetEdgeTypes(stepIndex) {
  138. store.stepConfigs[stepIndex].edgeTypes = stepIndex === 0 ? [...store.allEdgeTypes] : ['属于']
  139. store.stepConfigs[stepIndex].minScore = 0
  140. }
  141. const currentNodeName = computed(() => {
  142. if (!store.selectedNodeId) return '点击左侧节点查看'
  143. const node = store.getNode(store.selectedNodeId)
  144. return node ? node.name : store.selectedNodeId
  145. })
  146. // 判断当前是哪种游走类型
  147. const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(store.selectedNodeId))
  148. const isPostWalk = computed(() => store.selectedNodeId && store.shouldPostWalk(store.selectedNodeId))
  149. // 渲染相关图
  150. function renderGraph() {
  151. // 停止旧的 simulation
  152. if (simulation) {
  153. simulation.stop()
  154. simulation = null
  155. }
  156. const svg = d3.select(svgRef.value)
  157. svg.selectAll('*').remove()
  158. // 有选中节点或选中边时才渲染
  159. if (!store.selectedNodeId && !store.selectedEdgeId) return
  160. // 选中节点时,只有配置的节点类型才显示相关图
  161. if (store.selectedNodeId && !store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
  162. const container = containerRef.value
  163. if (!container) return
  164. const width = container.clientWidth
  165. const height = container.clientHeight
  166. if (width <= 0 || height <= 0) return
  167. svg.attr('viewBox', `0 0 ${width} ${height}`)
  168. // 中心节点(选中节点,或边的第一个端点)
  169. const centerNodeId = store.selectedNodeId || Array.from(store.highlightedNodeIds)[0]
  170. // 准备节点和边数据
  171. const nodes = []
  172. const links = []
  173. const nodeSet = new Set()
  174. // 显示所有高亮节点
  175. for (const nodeId of store.highlightedNodeIds) {
  176. const nodeData = store.getNode(nodeId)
  177. if (nodeData) {
  178. nodes.push({
  179. id: nodeId,
  180. ...nodeData,
  181. isCenter: nodeId === centerNodeId,
  182. isHighlighted: store.highlightedNodeIds.size > 1
  183. })
  184. nodeSet.add(nodeId)
  185. }
  186. }
  187. // 如果没有节点,不渲染
  188. if (nodes.length === 0) return
  189. // 使用游走时记录的边(只显示两端节点都存在的边)
  190. // 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
  191. const edges = store.postWalkedEdges.length > 0 ? store.postWalkedEdges : store.walkedEdges
  192. for (const edge of edges) {
  193. if (nodeSet.has(edge.source) && nodeSet.has(edge.target)) {
  194. links.push({ ...edge })
  195. }
  196. }
  197. const g = svg.append('g')
  198. // 缩放
  199. const zoom = d3.zoom()
  200. .scaleExtent([0.3, 3])
  201. .on('zoom', (e) => g.attr('transform', e.transform))
  202. svg.call(zoom)
  203. // 找到中心节点并固定在容器中心
  204. const centerNodeData = nodes.find(n => n.isCenter)
  205. if (centerNodeData) {
  206. centerNodeData.fx = width / 2
  207. centerNodeData.fy = height / 2
  208. }
  209. // 力导向模拟(中心节点已固定,其他节点围绕它布局)
  210. simulation = d3.forceSimulation(nodes)
  211. .force('link', d3.forceLink(links).id(d => d.id).distance(80))
  212. .force('charge', d3.forceManyBody().strength(-150))
  213. .force('collision', d3.forceCollide().radius(30))
  214. // 边
  215. const link = g.append('g')
  216. .selectAll('line')
  217. .data(links)
  218. .join('line')
  219. .attr('class', 'graph-link')
  220. .attr('stroke', d => getEdgeStyle(d).color)
  221. .attr('stroke-width', 1.5)
  222. .style('cursor', 'pointer')
  223. .on('click', (e, d) => {
  224. e.stopPropagation()
  225. // 传入完整边数据
  226. store.selectEdge({
  227. source: d.source.id || d.source,
  228. target: d.target.id || d.target,
  229. type: d.type,
  230. score: d.score
  231. })
  232. })
  233. // 边的分数标签
  234. const linkLabelData = links.filter(d => getEdgeStyle(d).scoreText)
  235. const linkLabel = g.append('g')
  236. .selectAll('g')
  237. .data(linkLabelData)
  238. .join('g')
  239. .attr('class', 'graph-link-label')
  240. linkLabel.append('rect')
  241. .attr('x', -14)
  242. .attr('y', -6)
  243. .attr('width', 28)
  244. .attr('height', 12)
  245. .attr('rx', 2)
  246. .attr('fill', '#1d232a')
  247. .attr('opacity', 0.9)
  248. linkLabel.append('text')
  249. .attr('text-anchor', 'middle')
  250. .attr('dy', '0.35em')
  251. .attr('fill', d => getEdgeStyle(d).color)
  252. .attr('font-size', '8px')
  253. .text(d => getEdgeStyle(d).scoreText)
  254. // 节点组
  255. const node = g.append('g')
  256. .selectAll('g')
  257. .data(nodes)
  258. .join('g')
  259. .attr('class', 'graph-node')
  260. .call(d3.drag()
  261. .on('start', (e, d) => {
  262. if (!e.active) simulation.alphaTarget(0.3).restart()
  263. d.fx = d.x
  264. d.fy = d.y
  265. })
  266. .on('drag', (e, d) => {
  267. d.fx = e.x
  268. d.fy = e.y
  269. })
  270. .on('end', (e, d) => {
  271. if (!e.active) simulation.alphaTarget(0)
  272. d.fx = null
  273. d.fy = null
  274. }))
  275. .on('click', (e, d) => {
  276. e.stopPropagation()
  277. store.selectNode(d.id)
  278. })
  279. .on('mouseenter', (e, d) => {
  280. if (d.isCenter) return // 中心节点不处理
  281. // 找从中心到 hover 节点的路径
  282. const pathNodes = findPath(centerNodeId, d.id, links)
  283. if (pathNodes.size > 0) {
  284. applyHoverHighlight(node, link, linkLabel, pathNodes)
  285. }
  286. })
  287. .on('mouseleave', () => {
  288. clearHoverHighlight(node, link, linkLabel)
  289. })
  290. // 节点形状(使用统一配置)
  291. node.each(function(d) {
  292. const el = d3.select(this)
  293. const style = getNodeStyle(d, { isCenter: d.isCenter })
  294. applyNodeShape(el, style)
  295. })
  296. // 节点标签
  297. node.append('text')
  298. .attr('dy', d => getNodeStyle(d, { isCenter: d.isCenter }).size / 2 + 12)
  299. .attr('text-anchor', 'middle')
  300. .text(d => d.name.length > 8 ? d.name.slice(0, 8) + '...' : d.name)
  301. // 更新位置
  302. simulation.on('tick', () => {
  303. link
  304. .attr('x1', d => d.source.x)
  305. .attr('y1', d => d.source.y)
  306. .attr('x2', d => d.target.x)
  307. .attr('y2', d => d.target.y)
  308. // 分数标签位置(边的中点)
  309. linkLabel.attr('transform', d => {
  310. const midX = (d.source.x + d.target.x) / 2
  311. const midY = (d.source.y + d.target.y) / 2
  312. return `translate(${midX},${midY})`
  313. })
  314. node.attr('transform', d => `translate(${d.x},${d.y})`)
  315. })
  316. // 应用初始高亮状态
  317. nextTick(updateHighlight)
  318. }
  319. // 点击空白取消激活
  320. function handleSvgClick(event) {
  321. if (event.target.tagName === 'svg') {
  322. store.clearSelection()
  323. }
  324. }
  325. // 统一高亮更新
  326. function updateHighlight() {
  327. const edgeSet = store.walkedEdgeSet.size > 0 ? store.walkedEdgeSet : store.postWalkedEdgeSet
  328. applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
  329. }
  330. // 监听高亮变化(walkedEdges 或 postWalkedEdges 变化时重新渲染)
  331. watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () => {
  332. nextTick(renderGraph)
  333. })
  334. // 监听边选中变化
  335. watch(() => store.selectedEdgeId, () => {
  336. nextTick(renderGraph)
  337. nextTick(updateHighlight)
  338. })
  339. // 监听高亮节点集合变化
  340. watch(() => store.highlightedNodeIds.size, updateHighlight)
  341. // 监听配置变化,重新选中触发游走
  342. watch([() => store.walkSteps, () => store.stepConfigs], () => {
  343. if (store.selectedNodeId && isPersonaWalk.value) {
  344. store.selectNode(store.selectedNodeId)
  345. }
  346. }, { deep: true })
  347. // 监听帖子游走配置变化
  348. watch(() => store.postWalkConfig, () => {
  349. if (store.selectedNodeId && isPostWalk.value) {
  350. store.selectNode(store.selectedNodeId)
  351. }
  352. }, { deep: true })
  353. // 监听 CSS 过渡结束后重新渲染
  354. function handleTransitionEnd(e) {
  355. if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
  356. if ((store.selectedNodeId || store.selectedEdgeId) && svgRef.value) {
  357. renderGraph()
  358. svgRef.value.style.opacity = '1'
  359. }
  360. }
  361. }
  362. // 布局变化时先淡出
  363. watch(() => store.expandedPanel, () => {
  364. if (svgRef.value) {
  365. svgRef.value.style.opacity = '0'
  366. }
  367. })
  368. onMounted(() => {
  369. nextTick(() => {
  370. renderGraph()
  371. // 监听父容器的过渡结束事件
  372. if (containerRef.value) {
  373. // 向上找到有 transition 的父容器
  374. let parent = containerRef.value.parentElement
  375. while (parent && !parent.classList.contains('transition-all')) {
  376. parent = parent.parentElement
  377. }
  378. if (parent) {
  379. parent.addEventListener('transitionend', handleTransitionEnd)
  380. }
  381. }
  382. })
  383. })
  384. // 组件卸载时清理
  385. onUnmounted(() => {
  386. if (containerRef.value) {
  387. let parent = containerRef.value.parentElement
  388. while (parent && !parent.classList.contains('transition-all')) {
  389. parent = parent.parentElement
  390. }
  391. if (parent) {
  392. parent.removeEventListener('transitionend', handleTransitionEnd)
  393. }
  394. }
  395. })
  396. </script>