GraphView.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  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" @click="handleSvgClick"></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, 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. // 用于 hover 联动的选择集和数据
  131. let graphNodeSelection = null
  132. let graphLinkSelection = null
  133. let graphLinkLabelSelection = null
  134. let graphLinksData = []
  135. let graphZoom = null
  136. let graphG = null
  137. // 游走配置操作(直接操作 store)
  138. function selectAllEdgeTypes(stepIndex) {
  139. store.stepConfigs[stepIndex].edgeTypes = [...store.allEdgeTypes]
  140. }
  141. function clearEdgeTypes(stepIndex) {
  142. store.stepConfigs[stepIndex].edgeTypes = []
  143. }
  144. function resetEdgeTypes(stepIndex) {
  145. store.stepConfigs[stepIndex].edgeTypes = stepIndex === 0 ? [...store.allEdgeTypes] : ['属于']
  146. store.stepConfigs[stepIndex].minScore = 0
  147. }
  148. const currentNodeName = computed(() => {
  149. if (!store.selectedNodeId) return '点击左侧节点查看'
  150. const node = store.getNode(store.selectedNodeId)
  151. return node ? node.name : store.selectedNodeId
  152. })
  153. // 判断当前是哪种游走类型
  154. const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(store.selectedNodeId))
  155. const isPostWalk = computed(() => store.selectedNodeId && store.shouldPostWalk(store.selectedNodeId))
  156. // 渲染相关图
  157. function renderGraph() {
  158. // 停止旧的 simulation
  159. if (simulation) {
  160. simulation.stop()
  161. simulation = null
  162. }
  163. const svg = d3.select(svgRef.value)
  164. svg.selectAll('*').remove()
  165. // 有选中节点或选中边时才渲染
  166. if (!store.selectedNodeId && !store.selectedEdgeId) return
  167. // 选中节点时,只有配置的节点类型才显示相关图
  168. if (store.selectedNodeId && !store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
  169. const container = containerRef.value
  170. if (!container) return
  171. const width = container.clientWidth
  172. const height = container.clientHeight
  173. if (width <= 0 || height <= 0) return
  174. svg.attr('viewBox', `0 0 ${width} ${height}`)
  175. // 中心节点(选中节点,或边的第一个端点)
  176. const centerNodeId = store.selectedNodeId || Array.from(store.highlightedNodeIds)[0]
  177. // 准备节点和边数据
  178. const nodes = []
  179. const links = []
  180. const nodeSet = new Set()
  181. // 显示所有高亮节点
  182. for (const nodeId of store.highlightedNodeIds) {
  183. const nodeData = store.getNode(nodeId)
  184. if (nodeData) {
  185. nodes.push({
  186. id: nodeId,
  187. ...nodeData,
  188. isCenter: nodeId === centerNodeId,
  189. isHighlighted: store.highlightedNodeIds.size > 1
  190. })
  191. nodeSet.add(nodeId)
  192. }
  193. }
  194. // 如果没有节点,不渲染
  195. if (nodes.length === 0) return
  196. // 使用游走时记录的边(只显示两端节点都存在的边)
  197. // 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
  198. const edges = store.postWalkedEdges.length > 0 ? store.postWalkedEdges : store.walkedEdges
  199. for (const edge of edges) {
  200. if (nodeSet.has(edge.source) && nodeSet.has(edge.target)) {
  201. links.push({ ...edge })
  202. }
  203. }
  204. const g = svg.append('g')
  205. graphG = g
  206. // 缩放
  207. const zoom = d3.zoom()
  208. .scaleExtent([0.3, 3])
  209. .on('zoom', (e) => g.attr('transform', e.transform))
  210. svg.call(zoom)
  211. graphZoom = zoom
  212. // 找到中心节点并固定在容器中心
  213. const centerNodeData = nodes.find(n => n.isCenter)
  214. if (centerNodeData) {
  215. centerNodeData.fx = width / 2
  216. centerNodeData.fy = height / 2
  217. }
  218. // 力导向模拟(中心节点已固定,其他节点围绕它布局)
  219. simulation = d3.forceSimulation(nodes)
  220. .force('link', d3.forceLink(links).id(d => d.id).distance(80))
  221. .force('charge', d3.forceManyBody().strength(-150))
  222. .force('collision', d3.forceCollide().radius(30))
  223. // 边
  224. const link = g.append('g')
  225. .selectAll('line')
  226. .data(links)
  227. .join('line')
  228. .attr('class', 'graph-link')
  229. .attr('stroke', d => getEdgeStyle(d).color)
  230. .attr('stroke-width', 1.5)
  231. .style('cursor', 'pointer')
  232. .on('click', (e, d) => {
  233. e.stopPropagation()
  234. // 传入完整边数据
  235. store.selectEdge({
  236. source: d.source.id || d.source,
  237. target: d.target.id || d.target,
  238. type: d.type,
  239. score: d.score
  240. })
  241. })
  242. // 边的分数标签
  243. const linkLabelData = links.filter(d => getEdgeStyle(d).scoreText)
  244. const linkLabel = g.append('g')
  245. .selectAll('g')
  246. .data(linkLabelData)
  247. .join('g')
  248. .attr('class', 'graph-link-label')
  249. linkLabel.append('rect')
  250. .attr('x', -14)
  251. .attr('y', -6)
  252. .attr('width', 28)
  253. .attr('height', 12)
  254. .attr('rx', 2)
  255. .attr('fill', '#1d232a')
  256. .attr('opacity', 0.9)
  257. linkLabel.append('text')
  258. .attr('text-anchor', 'middle')
  259. .attr('dy', '0.35em')
  260. .attr('fill', d => getEdgeStyle(d).color)
  261. .attr('font-size', '8px')
  262. .text(d => getEdgeStyle(d).scoreText)
  263. // 节点组
  264. const node = g.append('g')
  265. .selectAll('g')
  266. .data(nodes)
  267. .join('g')
  268. .attr('class', 'graph-node')
  269. .call(d3.drag()
  270. .on('start', (e, d) => {
  271. if (!e.active) simulation.alphaTarget(0.3).restart()
  272. d.fx = d.x
  273. d.fy = d.y
  274. })
  275. .on('drag', (e, d) => {
  276. d.fx = e.x
  277. d.fy = e.y
  278. })
  279. .on('end', (e, d) => {
  280. if (!e.active) simulation.alphaTarget(0)
  281. d.fx = null
  282. d.fy = null
  283. }))
  284. .on('mouseenter', function(e, d) {
  285. if (d.isCenter) return // 中心节点不处理
  286. // 路径计算由 store 统一处理,标记来源为 graph
  287. store.computeHoverPath(centerNodeId, d.id, 'graph')
  288. // 显示锁定按钮(在当前节点上)
  289. if (store.hoverPathNodes.size > 0) {
  290. showLockButton(this)
  291. }
  292. })
  293. .on('mouseleave', () => {
  294. // 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
  295. store.clearHover()
  296. if (store.lockedHoverNodeId) {
  297. // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
  298. // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
  299. applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, null)
  300. // 在锁定节点上显示解锁按钮
  301. graphNodeSelection.each(function(d) {
  302. if (d.id === store.lockedHoverNodeId) {
  303. showLockButton(this, true)
  304. }
  305. })
  306. } else {
  307. hideLockButton()
  308. }
  309. })
  310. .on('click', (e, d) => {
  311. e.stopPropagation()
  312. // 锁定状态下点击节点无效果,但提醒用户
  313. if (store.lockedHoverNodeId) {
  314. shakeLockButton()
  315. return
  316. }
  317. store.selectNode(d.id)
  318. })
  319. // 节点形状(使用统一配置)
  320. node.each(function(d) {
  321. const el = d3.select(this)
  322. const style = getNodeStyle(d, { isCenter: d.isCenter })
  323. applyNodeShape(el, style)
  324. })
  325. // 节点标签
  326. node.append('text')
  327. .attr('dy', d => getNodeStyle(d, { isCenter: d.isCenter }).size / 2 + 12)
  328. .attr('text-anchor', 'middle')
  329. .text(d => d.name.length > 8 ? d.name.slice(0, 8) + '...' : d.name)
  330. // 保存选择集和数据(用于 hover 联动)
  331. graphNodeSelection = node
  332. graphLinkSelection = link
  333. graphLinkLabelSelection = linkLabel
  334. graphLinksData = links
  335. // 更新位置
  336. simulation.on('tick', () => {
  337. link
  338. .attr('x1', d => d.source.x)
  339. .attr('y1', d => d.source.y)
  340. .attr('x2', d => d.target.x)
  341. .attr('y2', d => d.target.y)
  342. // 分数标签位置(边的中点)
  343. linkLabel.attr('transform', d => {
  344. const midX = (d.source.x + d.target.x) / 2
  345. const midY = (d.source.y + d.target.y) / 2
  346. return `translate(${midX},${midY})`
  347. })
  348. node.attr('transform', d => `translate(${d.x},${d.y})`)
  349. })
  350. // 应用初始高亮状态
  351. nextTick(updateHighlight)
  352. }
  353. // 在节点文字后面添加锁定按钮(作为独立的 tspan)
  354. function showLockButton(nodeEl, immediate = false) {
  355. if (!nodeEl) return
  356. const node = d3.select(nodeEl)
  357. const textEl = node.select('text')
  358. if (textEl.empty()) return
  359. // 获取当前节点 ID
  360. const nodeData = node.datum()
  361. const currentNodeId = nodeData?.data?.id || nodeData?.id
  362. // 判断当前节点是否是已锁定的节点
  363. const isThisNodeLocked = store.lockedHoverNodeId && store.lockedHoverNodeId === currentNodeId
  364. // 如果已有按钮,只更新状态
  365. let btn = textEl.select('.lock-btn')
  366. if (!btn.empty()) {
  367. btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
  368. .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
  369. if (!isThisNodeLocked) startBreathingAnimation(btn)
  370. return
  371. }
  372. // 创建按钮的函数
  373. const createBtn = () => {
  374. // 先清除当前 SVG 内其他节点的按钮(不影响另一边)
  375. if (svgRef.value) {
  376. d3.select(svgRef.value).selectAll('.lock-btn').remove()
  377. }
  378. // 添加按钮 tspan(紧跟在文字后面)
  379. const btn = textEl.append('tspan')
  380. .attr('class', 'lock-btn')
  381. .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
  382. .attr('font-weight', 'bold')
  383. .style('cursor', 'pointer')
  384. .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
  385. .on('click', (e) => {
  386. e.stopPropagation()
  387. handleLockClick()
  388. })
  389. // 呼吸灯动画(未锁定时)
  390. if (!isThisNodeLocked) {
  391. startBreathingAnimation(btn)
  392. }
  393. }
  394. createBtn()
  395. }
  396. // 呼吸灯动画(只有按钮部分,蓝色呼吸)
  397. function startBreathingAnimation(btn) {
  398. function breathe() {
  399. if (btn.empty() || !btn.node()) return
  400. if (store.lockedHoverNodeId) return
  401. btn
  402. .transition()
  403. .duration(800)
  404. .attr('fill', '#90cdf4')
  405. .transition()
  406. .duration(800)
  407. .attr('fill', '#63b3ed')
  408. .on('end', breathe)
  409. }
  410. breathe()
  411. }
  412. // 隐藏锁定按钮
  413. function hideLockButton() {
  414. if (svgRef.value) {
  415. d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
  416. }
  417. }
  418. // 抖动锁定按钮(提醒用户需要先解锁)
  419. function shakeLockButton() {
  420. d3.selectAll('.lock-btn')
  421. .interrupt()
  422. .attr('fill', '#fc8181') // 红色警告
  423. .transition().duration(50).attr('dx', 3)
  424. .transition().duration(50).attr('dx', -3)
  425. .transition().duration(50).attr('dx', 3)
  426. .transition().duration(50).attr('dx', -3)
  427. .transition().duration(50).attr('dx', 0)
  428. .transition().duration(200).attr('fill', '#f6ad55') // 恢复橙色
  429. }
  430. // 处理锁定按钮点击
  431. function handleLockClick() {
  432. const startNodeId = store.selectedNodeId
  433. const currentHoverNodeId = store.hoverNodeId
  434. // 判断是解锁还是新锁定
  435. if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
  436. // 点击的是当前锁定节点的按钮 → 解锁(弹出栈)
  437. store.clearLockedHover()
  438. // 如果还有上一层锁定,更新按钮状态
  439. if (store.lockedHoverNodeId) {
  440. d3.selectAll('.lock-btn')
  441. .interrupt()
  442. .text(' 🔓解锁')
  443. .attr('fill', '#f6ad55')
  444. } else {
  445. // 完全解锁,清除按钮
  446. d3.selectAll('.lock-btn').interrupt().remove()
  447. }
  448. } else if (currentHoverNodeId) {
  449. // 点击的是新 hover 节点的按钮 → 锁定新路径(压入栈)
  450. store.lockCurrentHover(startNodeId)
  451. // 更新按钮状态
  452. d3.selectAll('.lock-btn')
  453. .interrupt()
  454. .text(' 🔓解锁')
  455. .attr('fill', '#f6ad55')
  456. }
  457. }
  458. // 点击空白取消(锁定状态下无效果)
  459. function handleSvgClick(event) {
  460. // 锁定状态下,点击空白无效果
  461. if (store.lockedHoverNodeId) return
  462. if (event.target.tagName === 'svg') {
  463. store.clearSelection()
  464. hideLockButton()
  465. }
  466. }
  467. // 统一高亮更新
  468. function updateHighlight() {
  469. const edgeSet = store.walkedEdgeSet.size > 0 ? store.walkedEdgeSet : store.postWalkedEdgeSet
  470. applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
  471. }
  472. // 恢复锁定的 hover 状态(重新渲染后调用)
  473. function restoreLockedHover() {
  474. if (!store.lockedHoverNodeId || !graphNodeSelection) return
  475. // 恢复高亮效果(传入锁定路径)
  476. if (store.hoverPathNodes.size > 0) {
  477. const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
  478. applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath)
  479. }
  480. // 恢复锁定按钮:找到锁定节点的 DOM 元素
  481. graphNodeSelection.each(function(d) {
  482. if (d.id === store.lockedHoverNodeId) {
  483. showLockButton(this, true)
  484. }
  485. })
  486. }
  487. // 监听高亮变化(walkedEdges 或 postWalkedEdges 变化时重新渲染)
  488. watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () => {
  489. nextTick(renderGraph)
  490. })
  491. // 监听边选中变化
  492. watch(() => store.selectedEdgeId, () => {
  493. nextTick(renderGraph)
  494. nextTick(updateHighlight)
  495. })
  496. // 监听高亮节点集合变化
  497. watch(() => store.highlightedNodeIds.size, updateHighlight)
  498. // 监听 hover 状态变化(用于左右联动)
  499. watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
  500. if (!graphNodeSelection || !graphLinkSelection) return
  501. if (store.hoverPathNodes.size > 0) {
  502. // 应用 hover 高亮(支持嵌套:传入锁定路径)
  503. const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
  504. applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath)
  505. // 如果是从 PostTreeView 触发的,缩放到显示完整路径,并显示锁定按钮
  506. if (store.hoverSource === 'post-tree') {
  507. zoomToPathNodes(store.hoverPathNodes)
  508. }
  509. // 在对应节点上显示锁定按钮(无论来源)
  510. if (store.hoverNodeId) {
  511. graphNodeSelection.each(function(d) {
  512. if (d.id === store.hoverNodeId) {
  513. showLockButton(this)
  514. }
  515. })
  516. }
  517. } else {
  518. // 清除 hover,恢复原有高亮
  519. clearHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection)
  520. // 如果没有锁定,隐藏按钮
  521. if (!store.lockedHoverNodeId) {
  522. hideLockButton()
  523. }
  524. }
  525. })
  526. // 缩放到显示路径上的所有节点
  527. function zoomToPathNodes(pathNodes) {
  528. if (!graphNodeSelection || !graphZoom || !containerRef.value || !svgRef.value) return
  529. // 收集路径节点的位置
  530. const positions = []
  531. graphNodeSelection.each(function(d) {
  532. if (pathNodes.has(d.id)) {
  533. positions.push({ x: d.x, y: d.y })
  534. }
  535. })
  536. if (positions.length === 0) return
  537. // 计算边界框
  538. const minX = Math.min(...positions.map(p => p.x))
  539. const maxX = Math.max(...positions.map(p => p.x))
  540. const minY = Math.min(...positions.map(p => p.y))
  541. const maxY = Math.max(...positions.map(p => p.y))
  542. const width = containerRef.value.clientWidth
  543. const height = containerRef.value.clientHeight
  544. const padding = 60
  545. // 计算需要的缩放和平移
  546. const boxWidth = maxX - minX + padding * 2
  547. const boxHeight = maxY - minY + padding * 2
  548. const scale = Math.min(width / boxWidth, height / boxHeight, 1.5)
  549. const centerX = (minX + maxX) / 2
  550. const centerY = (minY + maxY) / 2
  551. const translateX = width / 2 - centerX * scale
  552. const translateY = height / 2 - centerY * scale
  553. const svg = d3.select(svgRef.value)
  554. svg.transition().duration(200).call(
  555. graphZoom.transform,
  556. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  557. )
  558. }
  559. // 监听配置变化,重新选中触发游走
  560. watch([() => store.walkSteps, () => store.stepConfigs], () => {
  561. if (store.selectedNodeId && isPersonaWalk.value) {
  562. store.selectNode(store.selectedNodeId)
  563. }
  564. }, { deep: true })
  565. // 监听帖子游走配置变化
  566. watch(() => store.postWalkConfig, () => {
  567. if (store.selectedNodeId && isPostWalk.value) {
  568. store.selectNode(store.selectedNodeId)
  569. }
  570. }, { deep: true })
  571. // 监听 CSS 过渡结束后重新渲染
  572. function handleTransitionEnd(e) {
  573. if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
  574. if ((store.selectedNodeId || store.selectedEdgeId) && svgRef.value) {
  575. renderGraph()
  576. svgRef.value.style.opacity = '1'
  577. nextTick(() => {
  578. updateHighlight()
  579. restoreLockedHover() // 恢复锁定的 hover 状态
  580. })
  581. }
  582. }
  583. }
  584. // 布局变化时先淡出
  585. watch(() => store.expandedPanel, () => {
  586. if (svgRef.value) {
  587. svgRef.value.style.opacity = '0'
  588. }
  589. })
  590. onMounted(() => {
  591. nextTick(() => {
  592. renderGraph()
  593. // 监听父容器的过渡结束事件
  594. if (containerRef.value) {
  595. // 向上找到有 transition 的父容器
  596. let parent = containerRef.value.parentElement
  597. while (parent && !parent.classList.contains('transition-all')) {
  598. parent = parent.parentElement
  599. }
  600. if (parent) {
  601. parent.addEventListener('transitionend', handleTransitionEnd)
  602. }
  603. }
  604. })
  605. })
  606. // 组件卸载时清理
  607. onUnmounted(() => {
  608. if (containerRef.value) {
  609. let parent = containerRef.value.parentElement
  610. while (parent && !parent.classList.contains('transition-all')) {
  611. parent = parent.parentElement
  612. }
  613. if (parent) {
  614. parent.removeEventListener('transitionend', handleTransitionEnd)
  615. }
  616. }
  617. })
  618. </script>