PostTreeView.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830
  1. <template>
  2. <div class="flex flex-col h-full">
  3. <!-- 头部 -->
  4. <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
  5. <span>帖子树</span>
  6. <div class="flex items-center gap-2">
  7. <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
  8. 已高亮 {{ store.highlightedNodeIds.size }} 个节点
  9. </span>
  10. <template v-if="showExpand">
  11. <button
  12. v-if="store.expandedPanel !== 'post-tree'"
  13. @click="store.expandPanel('post-tree')"
  14. class="btn btn-ghost btn-xs"
  15. title="放大"
  16. >⤢</button>
  17. <button
  18. v-if="store.expandedPanel !== 'default'"
  19. @click="store.resetLayout()"
  20. class="btn btn-ghost btn-xs"
  21. title="恢复"
  22. >⊡</button>
  23. </template>
  24. </div>
  25. </div>
  26. <!-- 帖子选择下拉框 -->
  27. <div class="px-4 py-2 bg-base-200 border-b border-base-300">
  28. <select
  29. v-model="selectedPostIdx"
  30. @change="onPostChange"
  31. class="select select-xs select-bordered w-full"
  32. >
  33. <option v-if="store.postList.length === 0" :value="-1">暂无帖子数据</option>
  34. <option
  35. v-for="post in store.postList"
  36. :key="post.index"
  37. :value="post.index"
  38. >
  39. {{ formatPostOption(post) }}
  40. </option>
  41. </select>
  42. </div>
  43. <!-- SVG 容器 -->
  44. <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
  45. <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
  46. </div>
  47. </div>
  48. </template>
  49. <script setup>
  50. import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
  51. import * as d3 from 'd3'
  52. import { useGraphStore } from '../stores/graph'
  53. import { getNodeStyle } from '../config/nodeStyle'
  54. import { getEdgeStyle } from '../config/edgeStyle'
  55. import { applyHighlight } from '../utils/highlight'
  56. const props = defineProps({
  57. showExpand: {
  58. type: Boolean,
  59. default: false
  60. }
  61. })
  62. const store = useGraphStore()
  63. const containerRef = ref(null)
  64. const svgRef = ref(null)
  65. // 当前选中的帖子索引
  66. const selectedPostIdx = ref(store.selectedPostIndex)
  67. // zoom 实例和主 g 元素
  68. let zoom = null
  69. let mainG = null
  70. let treeWidth = 0
  71. let treeHeight = 0
  72. // 帖子选择变化
  73. function onPostChange() {
  74. store.selectPost(selectedPostIdx.value)
  75. }
  76. // 格式化帖子选项显示
  77. function formatPostOption(post) {
  78. const date = post.createTime ? new Date(post.createTime * 1000).toLocaleDateString() : ''
  79. const title = post.postTitle || post.postId
  80. const shortTitle = title.length > 20 ? title.slice(0, 20) + '...' : title
  81. return date ? `${date} ${shortTitle}` : shortTitle
  82. }
  83. // 节点元素映射(统一存储所有节点位置)
  84. let nodeElements = {}
  85. let baseNodeElements = {} // 基础节点(帖子树+匹配层),不含游走节点
  86. let currentRoot = null
  87. // 处理节点点击
  88. function handleNodeClick(event, d) {
  89. event.stopPropagation()
  90. store.selectNode(d)
  91. }
  92. // 渲染树
  93. function renderTree() {
  94. const svg = d3.select(svgRef.value)
  95. svg.selectAll('*').remove()
  96. nodeElements = {}
  97. const treeData = store.postTreeData
  98. if (!treeData || !treeData.id) return
  99. const container = containerRef.value
  100. if (!container) return
  101. const width = container.clientWidth
  102. const height = container.clientHeight
  103. svg.attr('viewBox', `0 0 ${width} ${height}`)
  104. // 创建层级数据
  105. const root = d3.hierarchy(treeData)
  106. currentRoot = root
  107. // 智能计算树的尺寸(垂直布局)
  108. const allNodes = root.descendants()
  109. const maxDepth = d3.max(allNodes, d => d.depth)
  110. const leafCount = allNodes.filter(d => !d.children).length
  111. // 计算最长文字长度(用于动态调整间距)
  112. const maxTextLen = d3.max(allNodes, d => {
  113. const name = d.data.name || ''
  114. return Math.min(name.length, 10) // 最多显示10个字
  115. }) || 6
  116. // 动态计算节点间距(根据文字长度)
  117. const nodeSpacing = Math.max(60, maxTextLen * 12)
  118. // 宽度:基于叶子节点数量和文字间距
  119. treeWidth = Math.max(400, leafCount * nodeSpacing + 100)
  120. // 高度:根据深度,增大层间距避免垂直重叠
  121. treeHeight = Math.max(400, (maxDepth + 1) * 120 + 50)
  122. // 创建缩放行为
  123. zoom = d3.zoom()
  124. .scaleExtent([0.1, 3])
  125. .on('zoom', (e) => {
  126. mainG.attr('transform', e.transform)
  127. })
  128. svg.call(zoom)
  129. // 创建主组
  130. mainG = svg.append('g')
  131. // 创建树布局(垂直方向:从上到下)
  132. const treeLayout = d3.tree()
  133. .size([treeWidth - 100, treeHeight - 50])
  134. .separation((a, b) => {
  135. // 同级节点间距根据是否有子节点调整
  136. if (a.parent === b.parent) {
  137. // 叶子节点需要更大间距放文字
  138. const aIsLeaf = !a.children
  139. const bIsLeaf = !b.children
  140. if (aIsLeaf || bIsLeaf) return 1.5
  141. return 1
  142. }
  143. return 2
  144. })
  145. treeLayout(root)
  146. // 内容组(带偏移)
  147. const contentG = mainG.append('g')
  148. .attr('transform', 'translate(50, 25)')
  149. // 绘制边(垂直方向)
  150. contentG.append('g')
  151. .attr('class', 'tree-edges')
  152. .selectAll('.tree-link')
  153. .data(root.links())
  154. .join('path')
  155. .attr('class', 'tree-link')
  156. .attr('fill', 'none')
  157. .attr('stroke', '#3498db')
  158. .attr('stroke-opacity', 0.3)
  159. .attr('stroke-width', 1)
  160. .attr('d', d => {
  161. const midY = (d.source.y + d.target.y) / 2
  162. return `M${d.source.x},${d.source.y} C${d.source.x},${midY} ${d.target.x},${midY} ${d.target.x},${d.target.y}`
  163. })
  164. // 绘制节点(垂直布局:x 是水平位置,y 是垂直位置)
  165. const nodes = contentG.append('g')
  166. .attr('class', 'tree-nodes')
  167. .selectAll('.tree-node')
  168. .data(root.descendants())
  169. .join('g')
  170. .attr('class', 'tree-node')
  171. .attr('transform', d => `translate(${d.x},${d.y})`)
  172. .style('cursor', 'pointer')
  173. .on('click', handleNodeClick)
  174. // 节点形状(使用统一配置)
  175. nodes.each(function(d) {
  176. const el = d3.select(this)
  177. const style = getNodeStyle(d)
  178. if (style.shape === 'rect') {
  179. el.append('rect')
  180. .attr('class', 'tree-shape')
  181. .attr('x', -4)
  182. .attr('y', -4)
  183. .attr('width', 8)
  184. .attr('height', 8)
  185. .attr('rx', 1)
  186. .attr('fill', style.color)
  187. .attr('stroke', 'rgba(255,255,255,0.5)')
  188. .attr('stroke-width', 1)
  189. } else {
  190. el.append('circle')
  191. .attr('class', 'tree-shape')
  192. .attr('r', style.size / 2)
  193. .attr('fill', style.color)
  194. .attr('stroke', 'rgba(255,255,255,0.5)')
  195. .attr('stroke-width', 1)
  196. }
  197. nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
  198. })
  199. // 节点标签(使用统一配置)
  200. nodes.append('text')
  201. .attr('dy', d => d.children ? -10 : 4)
  202. .attr('dx', d => d.children ? 0 : 10)
  203. .attr('text-anchor', d => d.children ? 'middle' : 'start')
  204. .attr('fill', d => getNodeStyle(d).text.fill)
  205. .attr('font-size', d => getNodeStyle(d).text.fontSize)
  206. .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
  207. .text(d => {
  208. const name = d.data.name
  209. const maxLen = 10
  210. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  211. })
  212. // ========== 绘制匹配层 ==========
  213. renderMatchLayer(contentG, root, treeHeight)
  214. // 初始适应视图
  215. fitToView()
  216. }
  217. // 绘制匹配层(人设节点 + 连线)
  218. function renderMatchLayer(contentG, root, baseTreeHeight) {
  219. const postGraph = store.currentPostGraph
  220. if (!postGraph || !postGraph.edges) return
  221. // 提取匹配边(只取帖子->人设方向的)
  222. const matchEdges = []
  223. for (const edge of Object.values(postGraph.edges)) {
  224. if (edge.type === '匹配' && edge.source.startsWith('帖子:') && edge.target.startsWith('人设:')) {
  225. matchEdges.push(edge)
  226. }
  227. }
  228. if (matchEdges.length === 0) return
  229. // 收集匹配的人设节点(去重)
  230. const matchedPersonaMap = new Map()
  231. for (const edge of matchEdges) {
  232. if (!matchedPersonaMap.has(edge.target)) {
  233. // 从人设节点ID提取信息: "人设:目的点:标签:进行产品种草"
  234. const parts = edge.target.split(':')
  235. const name = parts[parts.length - 1]
  236. const dimension = parts[1] // 灵感点/目的点/关键点
  237. const type = parts[2] // 标签/分类/点
  238. matchedPersonaMap.set(edge.target, {
  239. id: edge.target,
  240. name: name,
  241. dimension: dimension,
  242. type: type,
  243. sourceEdges: [] // 连接的帖子节点
  244. })
  245. }
  246. matchedPersonaMap.get(edge.target).sourceEdges.push({
  247. sourceId: edge.source,
  248. score: edge.score
  249. })
  250. }
  251. const matchedPersonas = Array.from(matchedPersonaMap.values())
  252. if (matchedPersonas.length === 0) return
  253. // 计算匹配层的 Y 位置(树的最大深度 + 间距)
  254. const maxY = d3.max(root.descendants(), d => d.y) || 0
  255. const matchLayerY = maxY + 100
  256. // 计算匹配节点的 X 位置(均匀分布)
  257. const minX = d3.min(root.descendants(), d => d.x) || 0
  258. const maxX = d3.max(root.descendants(), d => d.x) || 0
  259. const matchSpacing = (maxX - minX) / Math.max(matchedPersonas.length - 1, 1)
  260. matchedPersonas.forEach((persona, i) => {
  261. persona.x = matchedPersonas.length === 1
  262. ? (minX + maxX) / 2
  263. : minX + i * matchSpacing
  264. persona.y = matchLayerY
  265. })
  266. // 收集匹配边数据(统一数据结构:source, target)
  267. const matchLinksData = []
  268. for (const persona of matchedPersonas) {
  269. for (const srcEdge of persona.sourceEdges) {
  270. const sourceNode = nodeElements[srcEdge.sourceId]
  271. if (!sourceNode) continue
  272. matchLinksData.push({
  273. source: srcEdge.sourceId,
  274. target: persona.id,
  275. score: srcEdge.score,
  276. srcX: sourceNode.x - 50,
  277. srcY: sourceNode.y - 25,
  278. tgtX: persona.x,
  279. tgtY: persona.y
  280. })
  281. }
  282. }
  283. // 绘制匹配连线(使用 data binding)
  284. const matchLinksG = contentG.append('g').attr('class', 'match-links')
  285. matchLinksG.selectAll('.match-link')
  286. .data(matchLinksData)
  287. .join('path')
  288. .attr('class', 'match-link')
  289. .attr('fill', 'none')
  290. .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
  291. .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
  292. .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
  293. .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
  294. .attr('d', d => {
  295. const midY = (d.srcY + d.tgtY) / 2
  296. return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
  297. })
  298. // 绘制分数标签(使用 data binding)
  299. const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
  300. const scoreGroups = matchLinksG.selectAll('.match-score')
  301. .data(scoreData)
  302. .join('g')
  303. .attr('class', 'match-score')
  304. .attr('transform', d => {
  305. const midX = (d.srcX + d.tgtX) / 2
  306. const midY = (d.srcY + d.tgtY) / 2
  307. return `translate(${midX}, ${midY})`
  308. })
  309. scoreGroups.append('rect')
  310. .attr('x', -14)
  311. .attr('y', -6)
  312. .attr('width', 28)
  313. .attr('height', 12)
  314. .attr('rx', 2)
  315. .attr('fill', '#1d232a')
  316. .attr('opacity', 0.9)
  317. scoreGroups.append('text')
  318. .attr('text-anchor', 'middle')
  319. .attr('dy', '0.35em')
  320. .attr('fill', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
  321. .attr('font-size', '8px')
  322. .text(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
  323. // 绘制匹配节点
  324. const matchNodesG = contentG.append('g').attr('class', 'match-nodes')
  325. const matchNodes = matchNodesG.selectAll('.match-node')
  326. .data(matchedPersonas)
  327. .join('g')
  328. .attr('class', 'match-node')
  329. .attr('transform', d => `translate(${d.x},${d.y})`)
  330. .style('cursor', 'pointer')
  331. .on('click', handleMatchNodeClick)
  332. // 匹配节点形状(使用统一配置)
  333. matchNodes.each(function(d) {
  334. const el = d3.select(this)
  335. const style = getNodeStyle(d, { isMatch: true })
  336. if (style.shape === 'rect') {
  337. el.append('rect')
  338. .attr('class', 'tree-shape')
  339. .attr('x', -4)
  340. .attr('y', -4)
  341. .attr('width', 8)
  342. .attr('height', 8)
  343. .attr('rx', 1)
  344. .attr('fill', style.color)
  345. .attr('stroke', 'rgba(255,255,255,0.5)')
  346. .attr('stroke-width', 1)
  347. } else {
  348. el.append('circle')
  349. .attr('class', 'tree-shape')
  350. .attr('r', 3)
  351. .attr('fill', style.color)
  352. .attr('stroke', 'rgba(255,255,255,0.5)')
  353. .attr('stroke-width', 1)
  354. }
  355. // 保存匹配节点位置(统一存入 nodeElements)
  356. nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
  357. })
  358. // 匹配节点标签(使用统一配置)
  359. matchNodes.append('text')
  360. .attr('dy', 4)
  361. .attr('dx', 10)
  362. .attr('text-anchor', 'start')
  363. .attr('fill', d => getNodeStyle(d, { isMatch: true }).text.fill)
  364. .attr('font-size', d => getNodeStyle(d, { isMatch: true }).text.fontSize)
  365. .attr('font-weight', d => getNodeStyle(d, { isMatch: true }).text.fontWeight)
  366. .text(d => {
  367. const name = d.name
  368. const maxLen = 10
  369. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  370. })
  371. // 更新总高度(用于 fitToView)
  372. treeHeight = matchLayerY + 50
  373. // 保存匹配层 Y 位置,供游走层使用
  374. lastMatchLayerY = matchLayerY
  375. // 保存基础节点快照(帖子树+匹配层),供游走层判断已有节点
  376. baseNodeElements = { ...nodeElements }
  377. }
  378. // 保存匹配层 Y 位置
  379. let lastMatchLayerY = 0
  380. // 绘制游走层(按层级渲染路径)
  381. function renderWalkedLayer() {
  382. if (!mainG) return
  383. // 移除旧的游走层
  384. mainG.selectAll('.walked-layer').remove()
  385. // 重置 nodeElements 为基础节点(清除之前的游走节点)
  386. nodeElements = { ...baseNodeElements }
  387. const paths = store.postWalkedPaths
  388. if (!paths.length) return
  389. const contentG = mainG.select('g')
  390. if (contentG.empty()) return
  391. // 创建游走层组
  392. const walkedG = contentG.append('g').attr('class', 'walked-layer')
  393. // ========== 按路径位置分层遍历,确定每个新节点的层数 ==========
  394. // nodeLayer: nodeId -> layer (已有节点从 nodeElements 推断,新节点计算得出)
  395. const nodeLayer = new Map()
  396. const newNodes = new Set() // 新增的节点
  397. console.log('=== renderWalkedLayer 层数计算 ===')
  398. console.log('路径数:', paths.length)
  399. console.log('lastMatchLayerY:', lastMatchLayerY)
  400. // 初始化:已有节点的层数(帖子树和匹配层)
  401. // 使用 baseNodeElements(不含之前的游走节点)
  402. // 匹配层的 Y 坐标是 lastMatchLayerY,作为基准层 0
  403. for (const [nodeId, info] of Object.entries(baseNodeElements)) {
  404. // 根据 Y 坐标推断层数(匹配层为0,帖子树在上面为负数)
  405. const layer = Math.round((info.y - 25 - lastMatchLayerY) / 80)
  406. nodeLayer.set(nodeId, Math.min(layer, 0)) // 已有节点层数 <= 0
  407. }
  408. console.log('基础节点数:', nodeLayer.size)
  409. // 找出所有路径的最大长度
  410. const maxPathLen = Math.max(...paths.map(p => p.nodes.length))
  411. console.log('最大路径长度:', maxPathLen)
  412. // 按位置分层遍历(从第2个节点开始,index=1)
  413. for (let pos = 1; pos < maxPathLen; pos++) {
  414. console.log(`--- 处理位置 ${pos} ---`)
  415. for (const path of paths) {
  416. if (pos >= path.nodes.length) continue
  417. const nodeId = path.nodes[pos]
  418. const prevNodeId = path.nodes[pos - 1]
  419. // 如果已经在树上,跳过
  420. if (nodeLayer.has(nodeId)) {
  421. console.log(` 节点 ${nodeId}: 已存在,层数=${nodeLayer.get(nodeId)},跳过`)
  422. continue
  423. }
  424. // 找前驱节点的层数
  425. const prevLayer = nodeLayer.get(prevNodeId) ?? 0
  426. const newLayer = prevLayer + 1
  427. console.log(` 节点 ${nodeId}: 前驱=${prevNodeId}(层${prevLayer}) → 新层数=${newLayer}`)
  428. // 新节点层数 = 前驱层数 + 1
  429. nodeLayer.set(nodeId, newLayer)
  430. newNodes.add(nodeId)
  431. }
  432. }
  433. console.log('新增节点数:', newNodes.size)
  434. console.log('新增节点:', Array.from(newNodes))
  435. // ========== 按层分组新节点 ==========
  436. const layerGroups = new Map() // layer -> Set of nodeIds
  437. for (const nodeId of newNodes) {
  438. const layer = nodeLayer.get(nodeId)
  439. if (!layerGroups.has(layer)) {
  440. layerGroups.set(layer, new Set())
  441. }
  442. layerGroups.get(layer).add(nodeId)
  443. }
  444. // ========== 计算新节点位置 ==========
  445. const treeNodes = Object.values(nodeElements)
  446. const minX = d3.min(treeNodes, d => d.x) || 50
  447. const maxX = d3.max(treeNodes, d => d.x) || 300
  448. const layerSpacing = 80 // 层间距
  449. // nodePositions 只存储新节点的位置
  450. const nodePositions = {}
  451. for (const [layer, nodeIds] of layerGroups) {
  452. const nodesAtLayer = Array.from(nodeIds)
  453. const layerY = lastMatchLayerY + layer * layerSpacing
  454. // 新节点均匀分布
  455. const spacing = (maxX - minX - 100) / Math.max(nodesAtLayer.length - 1, 1)
  456. nodesAtLayer.forEach((nodeId, i) => {
  457. nodePositions[nodeId] = {
  458. x: nodesAtLayer.length === 1 ? (minX + maxX) / 2 - 50 : (minX - 50) + i * spacing,
  459. y: layerY
  460. }
  461. })
  462. }
  463. // ========== 收集所有边 ==========
  464. const allEdges = new Map()
  465. for (const path of paths) {
  466. for (const edge of path.edges) {
  467. const edgeKey = `${edge.source}->${edge.target}`
  468. if (!allEdges.has(edgeKey)) {
  469. allEdges.set(edgeKey, edge)
  470. }
  471. }
  472. }
  473. // 获取节点位置的辅助函数(优先新节点位置,然后已有节点位置)
  474. function getNodePos(nodeId) {
  475. if (nodePositions[nodeId]) {
  476. return nodePositions[nodeId]
  477. }
  478. if (nodeElements[nodeId]) {
  479. return {
  480. x: nodeElements[nodeId].x - 50,
  481. y: nodeElements[nodeId].y - 25
  482. }
  483. }
  484. return null
  485. }
  486. // ========== 绘制所有路径上的边 ==========
  487. const edgesData = []
  488. console.log('=== 绘制边 ===')
  489. console.log('总边数:', allEdges.size)
  490. for (const edge of allEdges.values()) {
  491. const srcPos = getNodePos(edge.source)
  492. const tgtPos = getNodePos(edge.target)
  493. console.log(`边 ${edge.source} -> ${edge.target}:`,
  494. 'srcPos=', srcPos ? `(${srcPos.x.toFixed(0)},${srcPos.y.toFixed(0)})` : 'null',
  495. 'tgtPos=', tgtPos ? `(${tgtPos.x.toFixed(0)},${tgtPos.y.toFixed(0)})` : 'null'
  496. )
  497. if (srcPos && tgtPos) {
  498. edgesData.push({
  499. ...edge,
  500. srcX: srcPos.x,
  501. srcY: srcPos.y,
  502. tgtX: tgtPos.x,
  503. tgtY: tgtPos.y
  504. })
  505. } else {
  506. console.log(' ⚠️ 跳过:位置缺失')
  507. }
  508. }
  509. console.log('实际绘制边数:', edgesData.length)
  510. walkedG.selectAll('.walked-link')
  511. .data(edgesData)
  512. .join('path')
  513. .attr('class', 'walked-link')
  514. .attr('fill', 'none')
  515. .attr('stroke', d => getEdgeStyle({ type: d.type, score: d.score }).color)
  516. .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }).opacity)
  517. .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
  518. .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
  519. .attr('d', d => {
  520. // 同一层的边(Y坐标相近)用向下弯曲的曲线
  521. if (Math.abs(d.srcY - d.tgtY) < 10) {
  522. const controlY = d.srcY + 50 // 向下弯曲
  523. const midX = (d.srcX + d.tgtX) / 2
  524. return `M${d.srcX},${d.srcY} Q${midX},${controlY} ${d.tgtX},${d.tgtY}`
  525. }
  526. // 不同层的边用 S 形曲线
  527. const midY = (d.srcY + d.tgtY) / 2
  528. return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
  529. })
  530. // 绘制分数标签
  531. const scoreData = edgesData.filter(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
  532. const scoreGroups = walkedG.selectAll('.walked-score')
  533. .data(scoreData)
  534. .join('g')
  535. .attr('class', 'walked-score')
  536. .attr('transform', d => {
  537. const midX = (d.srcX + d.tgtX) / 2
  538. // 同一层的边用向下弯曲曲线,分数标签放在曲线中点(t=0.5 时 y = srcY + 25)
  539. const midY = Math.abs(d.srcY - d.tgtY) < 10 ? d.srcY + 25 : (d.srcY + d.tgtY) / 2
  540. return `translate(${midX}, ${midY})`
  541. })
  542. scoreGroups.append('rect')
  543. .attr('x', -14).attr('y', -6).attr('width', 28).attr('height', 12)
  544. .attr('rx', 2).attr('fill', '#1d232a').attr('opacity', 0.9)
  545. scoreGroups.append('text')
  546. .attr('text-anchor', 'middle').attr('dy', '0.35em')
  547. .attr('fill', d => getEdgeStyle({ type: d.type, score: d.score }).color)
  548. .attr('font-size', '8px')
  549. .text(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
  550. // ========== 绘制新节点(nodePositions 中的都是新节点) ==========
  551. const newNodesData = []
  552. for (const [nodeId, pos] of Object.entries(nodePositions)) {
  553. // 获取节点数据
  554. const nodeData = store.postWalkedNodes.find(n => n.id === nodeId)
  555. if (nodeData) {
  556. newNodesData.push({ ...nodeData, x: pos.x, y: pos.y })
  557. }
  558. }
  559. const walkedNodeGroups = walkedG.selectAll('.walked-node')
  560. .data(newNodesData)
  561. .join('g')
  562. .attr('class', 'walked-node')
  563. .attr('transform', d => `translate(${d.x},${d.y})`)
  564. .style('cursor', 'pointer')
  565. .on('click', (event, d) => {
  566. event.stopPropagation()
  567. store.selectNode(d)
  568. })
  569. // 节点形状
  570. walkedNodeGroups.each(function(d) {
  571. const el = d3.select(this)
  572. const style = getNodeStyle(d)
  573. if (style.shape === 'rect') {
  574. el.append('rect')
  575. .attr('class', 'walked-shape')
  576. .attr('x', -4).attr('y', -4).attr('width', 8).attr('height', 8)
  577. .attr('rx', 1).attr('fill', style.color)
  578. .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
  579. } else {
  580. el.append('circle')
  581. .attr('class', 'walked-shape')
  582. .attr('r', 3).attr('fill', style.color)
  583. .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
  584. }
  585. // 保存节点位置
  586. nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
  587. })
  588. // 节点标签
  589. walkedNodeGroups.append('text')
  590. .attr('dy', 4).attr('dx', 10).attr('text-anchor', 'start')
  591. .attr('fill', d => getNodeStyle(d).text.fill)
  592. .attr('font-size', d => getNodeStyle(d).text.fontSize)
  593. .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
  594. .text(d => {
  595. const name = d.name
  596. const maxLen = 10
  597. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  598. })
  599. // 更新高度(如果有新节点)
  600. if (Object.keys(nodePositions).length > 0) {
  601. const maxY = Math.max(...Object.values(nodePositions).map(p => p.y))
  602. treeHeight = Math.max(treeHeight, maxY + 50)
  603. }
  604. }
  605. // 匹配节点点击处理
  606. function handleMatchNodeClick(event, d) {
  607. event.stopPropagation()
  608. store.selectNode(d)
  609. }
  610. // 适应视图(自动缩放以显示全部内容)
  611. function fitToView() {
  612. if (!zoom || !mainG || !containerRef.value) return
  613. const container = containerRef.value
  614. const width = container.clientWidth
  615. const height = container.clientHeight
  616. // 计算缩放比例以适应容器
  617. const scaleX = width / treeWidth
  618. const scaleY = height / treeHeight
  619. const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
  620. // 计算居中偏移
  621. const translateX = (width - treeWidth * scale) / 2
  622. const translateY = (height - treeHeight * scale) / 2
  623. const svg = d3.select(svgRef.value)
  624. svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
  625. }
  626. // 定位到指定节点
  627. function zoomToNode(nodeId) {
  628. const nodeInfo = nodeElements[nodeId]
  629. if (!nodeInfo || !zoom || !containerRef.value) return
  630. const container = containerRef.value
  631. const width = container.clientWidth
  632. const height = container.clientHeight
  633. // 计算平移使节点居中
  634. const scale = 1
  635. const translateX = width / 2 - nodeInfo.x * scale
  636. const translateY = height / 2 - nodeInfo.y * scale
  637. const svg = d3.select(svgRef.value)
  638. svg.transition().duration(300).call(
  639. zoom.transform,
  640. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  641. )
  642. }
  643. // 更新高亮/置灰状态
  644. function updateHighlight() {
  645. // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
  646. const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
  647. applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
  648. }
  649. // 点击空白取消激活
  650. function handleSvgClick(event) {
  651. const target = event.target
  652. if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
  653. store.clearSelection()
  654. }
  655. }
  656. // 监听选中/高亮变化,统一更新
  657. watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
  658. updateHighlight()
  659. if (nodeId && nodeId !== oldNodeId) {
  660. zoomToNode(nodeId)
  661. }
  662. })
  663. watch(() => store.highlightedNodeIds.size, updateHighlight)
  664. // 监听帖子游走结果变化,渲染游走层
  665. watch(() => store.postWalkedNodes.length, () => {
  666. nextTick(renderWalkedLayer)
  667. })
  668. // 监听当前帖子变化,重新渲染树
  669. watch(() => store.currentPostGraph, () => {
  670. nextTick(() => {
  671. renderTree()
  672. })
  673. }, { immediate: false })
  674. // 监听 selectedPostIndex 变化,同步下拉框
  675. watch(() => store.selectedPostIndex, (newIdx) => {
  676. selectedPostIdx.value = newIdx
  677. })
  678. // 监听布局变化,过渡结束后重新适应视图
  679. function handleTransitionEnd(e) {
  680. if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
  681. nextTick(() => {
  682. renderTree()
  683. })
  684. }
  685. }
  686. let transitionParent = null
  687. onMounted(() => {
  688. nextTick(() => {
  689. renderTree()
  690. })
  691. // 监听父容器的过渡结束事件
  692. if (containerRef.value) {
  693. let parent = containerRef.value.parentElement
  694. while (parent && !parent.classList.contains('transition-all')) {
  695. parent = parent.parentElement
  696. }
  697. if (parent) {
  698. transitionParent = parent
  699. parent.addEventListener('transitionend', handleTransitionEnd)
  700. }
  701. }
  702. })
  703. onUnmounted(() => {
  704. if (transitionParent) {
  705. transitionParent.removeEventListener('transitionend', handleTransitionEnd)
  706. }
  707. })
  708. </script>