PostTreeView.vue 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732
  1. <template>
  2. <div class="flex h-full">
  3. <!-- 左侧主区域:待解构帖子 -->
  4. <div v-if="showPostTree" class="flex flex-col flex-1 min-w-0">
  5. <!-- 头部 -->
  6. <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
  7. <span>待解构帖子</span>
  8. <div class="flex items-center gap-2">
  9. <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
  10. 已高亮 {{ store.highlightedNodeIds.size }} 个节点
  11. </span>
  12. <template v-if="showExpand">
  13. <button
  14. v-if="store.expandedPanel !== 'post-tree'"
  15. @click="store.expandPanel('post-tree')"
  16. class="btn btn-ghost btn-xs"
  17. title="放大"
  18. >⤢</button>
  19. <button
  20. v-if="store.expandedPanel !== 'default'"
  21. @click="store.resetLayout()"
  22. class="btn btn-ghost btn-xs"
  23. title="恢复"
  24. >⊡</button>
  25. </template>
  26. </div>
  27. </div>
  28. <!-- 帖子筛选列表 -->
  29. <div class="px-2 py-1.5 bg-base-200 border-b border-base-300 flex gap-1 overflow-x-auto">
  30. <button
  31. v-for="post in store.postList"
  32. :key="post.index"
  33. @click="selectPost(post.index)"
  34. class="btn btn-xs shrink-0 transition-colors"
  35. :class="selectedPostIdx === post.index ? 'btn-primary' : 'btn-ghost'"
  36. :title="post.postTitle"
  37. >
  38. {{ formatPostTitle(post) }}
  39. </button>
  40. <span v-if="store.postList.length === 0" class="text-xs text-base-content/40 px-2">暂无帖子</span>
  41. </div>
  42. <!-- SVG 容器 -->
  43. <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
  44. <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
  45. </div>
  46. </div>
  47. <!-- 右侧:匹配列表 + 详情 -->
  48. <div
  49. v-if="showMatchList || showDetail"
  50. class="shrink-0 bg-base-200 border-l border-base-300 flex flex-col text-xs transition-all duration-200"
  51. :class="showPostTree ? 'w-72' : 'flex-1'"
  52. >
  53. <!-- 匹配列表(1/3高度) -->
  54. <div
  55. v-if="showMatchList"
  56. class="flex flex-col border-b border-base-300 transition-all duration-200"
  57. :class="matchListCollapsed ? 'h-8' : (showDetail ? 'h-1/3' : 'flex-1')"
  58. >
  59. <div
  60. class="px-3 py-2 bg-base-300 text-base-content/60 flex items-center justify-between shrink-0 cursor-pointer"
  61. @click="emit('update:matchListCollapsed', !matchListCollapsed)"
  62. >
  63. <div class="flex items-center gap-2">
  64. <span class="transition-transform" :class="{ '-rotate-90': matchListCollapsed }">▼</span>
  65. <span>匹配列表</span>
  66. </div>
  67. <span class="text-base-content/40">{{ sortedMatchEdges.length }}</span>
  68. </div>
  69. <div v-show="!matchListCollapsed" class="flex-1 overflow-y-auto">
  70. <div
  71. v-for="(edge, idx) in sortedMatchEdges"
  72. :key="idx"
  73. class="px-3 py-1.5 hover:bg-base-300 cursor-pointer border-b border-base-300/50 transition-colors"
  74. :class="{ 'bg-primary/10': store.selectedEdgeId === getEdgeId(edge) }"
  75. @click="onMatchClick(edge)"
  76. >
  77. <div class="flex items-center gap-1.5">
  78. <!-- 源节点样式(帖子域-空心) -->
  79. <span
  80. class="w-2 h-2 shrink-0 rounded-full border-2"
  81. :style="{ borderColor: getSourceNodeColor(edge), backgroundColor: 'transparent' }"
  82. ></span>
  83. <span class="truncate text-base-content/80" :title="edge.sourceName">{{ edge.sourceName }}</span>
  84. <!-- 分数(带边颜色) -->
  85. <span
  86. class="px-1 text-[10px] font-medium shrink-0 border-t border-b"
  87. :style="{ color: getScoreColor(edge.score), borderColor: getScoreColor(edge.score) }"
  88. >{{ edge.score != null ? edge.score.toFixed(2) : '-' }}</span>
  89. <!-- 目标节点样式(人设域-实心) -->
  90. <span
  91. class="w-2 h-2 shrink-0 rounded-full"
  92. :style="{ backgroundColor: getTargetNodeColor(edge) }"
  93. ></span>
  94. <span class="truncate text-base-content/60" :title="edge.targetName">{{ edge.targetName }}</span>
  95. </div>
  96. </div>
  97. <div v-if="sortedMatchEdges.length === 0" class="px-3 py-4 text-base-content/40 text-center">
  98. 暂无匹配
  99. </div>
  100. </div>
  101. </div>
  102. <!-- 详情 -->
  103. <div v-if="showDetail" class="flex-1 flex flex-col min-h-0">
  104. <div class="px-3 py-2 bg-base-300 text-base-content/60 shrink-0 flex items-center justify-between">
  105. <span>详情</span>
  106. <label class="swap swap-flip text-[10px]">
  107. <input type="checkbox" v-model="showRawData" />
  108. <span class="swap-on">JSON</span>
  109. <span class="swap-off">渲染</span>
  110. </label>
  111. </div>
  112. <div class="flex-1 overflow-y-auto p-3 space-y-3">
  113. <!-- 原始JSON模式 -->
  114. <template v-if="showRawData && (displayNode || store.selectedEdge)">
  115. <div class="relative">
  116. <button
  117. @click="copyJson"
  118. class="absolute top-1 right-1 btn btn-ghost btn-xs opacity-60 hover:opacity-100"
  119. :title="copySuccess ? '已复制' : '复制'"
  120. >
  121. <span v-if="copySuccess">✓</span>
  122. <span v-else>📋</span>
  123. </button>
  124. <pre class="text-[10px] bg-base-100 p-2 pr-8 rounded overflow-x-auto whitespace-pre-wrap break-all select-all">{{ JSON.stringify(displayNode || store.selectedEdge, null, 2) }}</pre>
  125. </div>
  126. </template>
  127. <!-- 渲染模式 -->
  128. <template v-else-if="!showRawData">
  129. <!-- 节点详情(hover 优先于 selected) -->
  130. <template v-if="displayNode">
  131. <div class="flex items-center gap-2">
  132. <!-- hover 标识 -->
  133. <span v-if="store.hoverNode" class="text-[10px] text-warning/60">[hover]</span>
  134. <!-- 节点样式:空心(帖子域)或实心(人设域) -->
  135. <span
  136. class="w-2.5 h-2.5 shrink-0"
  137. :class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
  138. :style="displayNodeStyle.hollow
  139. ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
  140. : { backgroundColor: displayNodeStyle.color }"
  141. ></span>
  142. <span class="text-primary font-medium truncate">{{ displayNode.name }}</span>
  143. </div>
  144. <div class="space-y-1.5 text-[11px]">
  145. <template v-for="(value, key) in displayNode" :key="key">
  146. <template v-if="!hiddenNodeFields.includes(key) && key !== 'name' && value !== null && value !== undefined && value !== ''">
  147. <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
  148. <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
  149. <span class="text-right break-all">{{ formatValue(value) }}</span>
  150. </div>
  151. <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
  152. <span class="text-base-content/50">{{ formatKey(key) }}</span>
  153. <div class="pl-2 border-l border-base-content/20 space-y-1">
  154. <template v-for="(v, k) in value" :key="k">
  155. <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
  156. <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
  157. <span class="text-right break-all">{{ formatValue(v) }}</span>
  158. </div>
  159. </template>
  160. </div>
  161. </div>
  162. </template>
  163. </template>
  164. </div>
  165. <!-- 入边列表 -->
  166. <div v-if="nodeInEdges.length > 0" class="mt-3 pt-2 border-t border-base-content/10">
  167. <div
  168. class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
  169. @click="openEdgeListModal('in', nodeInEdges)"
  170. >入边 ({{ nodeInEdges.length }}) ›</div>
  171. <div class="space-y-1 max-h-24 overflow-y-auto">
  172. <div
  173. v-for="edge in nodeInEdges"
  174. :key="`in-${edge.source}-${edge.type}`"
  175. class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
  176. @click="openEdgeModal(edge)"
  177. >
  178. <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
  179. <span class="truncate flex-1">{{ getNodeName(edge.source) }}</span>
  180. <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
  181. </div>
  182. </div>
  183. </div>
  184. <!-- 出边列表 -->
  185. <div v-if="nodeOutEdges.length > 0" class="mt-2 pt-2 border-t border-base-content/10">
  186. <div
  187. class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
  188. @click="openEdgeListModal('out', nodeOutEdges)"
  189. >出边 ({{ nodeOutEdges.length }}) ›</div>
  190. <div class="space-y-1 max-h-24 overflow-y-auto">
  191. <div
  192. v-for="edge in nodeOutEdges"
  193. :key="`out-${edge.target}-${edge.type}`"
  194. class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
  195. @click="openEdgeModal(edge)"
  196. >
  197. <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
  198. <span class="truncate flex-1">{{ getNodeName(edge.target) }}</span>
  199. <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
  200. </div>
  201. </div>
  202. </div>
  203. </template>
  204. <!-- 边详情(hover 优先于 selected) -->
  205. <template v-else-if="displayEdge">
  206. <div class="flex items-center gap-2">
  207. <span class="w-4 h-0.5 shrink-0" :style="{ backgroundColor: displayEdgeColor }"></span>
  208. <span class="text-secondary font-medium">{{ displayEdge.type }} 边</span>
  209. </div>
  210. <div class="space-y-1.5 text-[11px]">
  211. <template v-for="(value, key) in displayEdge" :key="key">
  212. <template v-if="key !== 'index' && value !== null && value !== undefined && value !== ''">
  213. <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
  214. <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
  215. <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
  216. </div>
  217. <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
  218. <span class="text-base-content/50">{{ formatKey(key) }}</span>
  219. <div class="pl-2 border-l border-base-content/20 space-y-1">
  220. <template v-for="(v, k) in value" :key="k">
  221. <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
  222. <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
  223. <span class="text-right break-all">{{ formatValue(v) }}</span>
  224. </div>
  225. </template>
  226. </div>
  227. </div>
  228. </template>
  229. </template>
  230. </div>
  231. </template>
  232. <!-- 无选中 -->
  233. <div v-else class="text-base-content/40 text-center py-4">
  234. 点击节点或边查看详情
  235. </div>
  236. </template>
  237. </div>
  238. </div>
  239. </div>
  240. <!-- 边详情模态框 -->
  241. <dialog v-if="modalEdge" class="modal modal-open">
  242. <div class="modal-box max-w-md">
  243. <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeEdgeModal">✕</button>
  244. <h3 class="font-bold text-lg flex items-center gap-2">
  245. <span class="w-4 h-0.5" :style="{ backgroundColor: edgeTypeColors[modalEdge.type] }"></span>
  246. {{ modalEdge.type }} 边
  247. </h3>
  248. <div class="py-4 space-y-2 text-sm">
  249. <template v-for="(value, key) in modalEdge" :key="key">
  250. <template v-if="key !== 'index' && value !== null && value !== undefined && value !== ''">
  251. <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
  252. <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
  253. <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
  254. </div>
  255. <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
  256. <span class="text-base-content/50">{{ formatKey(key) }}</span>
  257. <div class="pl-3 border-l border-base-content/20 space-y-1 text-xs">
  258. <template v-for="(v, k) in value" :key="k">
  259. <div v-if="k !== 'index' && v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2">
  260. <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
  261. <span class="text-right break-all">{{ formatValue(v) }}</span>
  262. </div>
  263. </template>
  264. </div>
  265. </div>
  266. </template>
  267. </template>
  268. </div>
  269. </div>
  270. <form method="dialog" class="modal-backdrop">
  271. <button @click="closeEdgeModal">close</button>
  272. </form>
  273. </dialog>
  274. <!-- 边列表模态框 -->
  275. <dialog v-if="edgeListModal.show" class="modal modal-open">
  276. <div class="modal-box max-w-2xl max-h-[80vh]">
  277. <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeEdgeListModal">✕</button>
  278. <h3 class="font-bold text-lg">{{ edgeListModal.type === 'in' ? '入边' : '出边' }}列表 ({{ edgeListModal.edges.length }})</h3>
  279. <div class="py-4 overflow-y-auto max-h-[60vh]">
  280. <div class="space-y-3">
  281. <div
  282. v-for="(edge, idx) in edgeListModal.edges"
  283. :key="idx"
  284. class="p-3 bg-base-200 rounded-lg"
  285. >
  286. <div class="flex items-center gap-2 mb-2 pb-2 border-b border-base-300">
  287. <span class="w-4 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
  288. <span class="font-medium">{{ edge.type }}</span>
  289. <span class="text-base-content/50 text-sm">
  290. {{ edgeListModal.type === 'in' ? getNodeName(edge.source) : getNodeName(edge.target) }}
  291. </span>
  292. <span v-if="edge.score != null" class="ml-auto text-primary">{{ edge.score.toFixed(2) }}</span>
  293. </div>
  294. <div class="space-y-1 text-sm">
  295. <template v-for="(value, key) in edge" :key="key">
  296. <template v-if="!hiddenNodeFields.includes(key) && key !== 'type' && value !== null && value !== undefined && value !== ''">
  297. <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
  298. <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
  299. <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
  300. </div>
  301. <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
  302. <span class="text-base-content/50">{{ formatKey(key) }}</span>
  303. <div class="pl-3 border-l border-base-content/20 space-y-1 text-xs">
  304. <template v-for="(v, k) in value" :key="k">
  305. <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2">
  306. <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
  307. <span class="text-right break-all">{{ formatValue(v) }}</span>
  308. </div>
  309. </template>
  310. </div>
  311. </div>
  312. </template>
  313. </template>
  314. </div>
  315. </div>
  316. </div>
  317. </div>
  318. </div>
  319. <form method="dialog" class="modal-backdrop">
  320. <button @click="closeEdgeListModal">close</button>
  321. </form>
  322. </dialog>
  323. </div>
  324. </template>
  325. <script setup>
  326. import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
  327. import * as d3 from 'd3'
  328. import { useGraphStore } from '../stores/graph'
  329. import { getNodeStyle, applyNodeShape, dimColors } from '../config/nodeStyle'
  330. import { getEdgeStyle, edgeTypeColors } from '../config/edgeStyle'
  331. import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
  332. const props = defineProps({
  333. showExpand: {
  334. type: Boolean,
  335. default: false
  336. },
  337. showPostTree: {
  338. type: Boolean,
  339. default: true
  340. },
  341. showMatchList: {
  342. type: Boolean,
  343. default: true
  344. },
  345. showDetail: {
  346. type: Boolean,
  347. default: true
  348. },
  349. matchListCollapsed: {
  350. type: Boolean,
  351. default: false
  352. }
  353. })
  354. const emit = defineEmits(['update:matchListCollapsed'])
  355. // 不需要显示的节点字段
  356. const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy']
  357. const store = useGraphStore()
  358. const containerRef = ref(null)
  359. const svgRef = ref(null)
  360. // 详情显示模式:原始JSON / 渲染
  361. const showRawData = ref(false)
  362. const copySuccess = ref(false)
  363. // 边详情模态框
  364. const modalEdge = ref(null)
  365. function openEdgeModal(edge) {
  366. modalEdge.value = edge
  367. }
  368. function closeEdgeModal() {
  369. modalEdge.value = null
  370. }
  371. // 边列表模态框
  372. const edgeListModal = ref({
  373. show: false,
  374. type: 'in', // 'in' or 'out'
  375. edges: []
  376. })
  377. function openEdgeListModal(type, edges) {
  378. edgeListModal.value = {
  379. show: true,
  380. type,
  381. edges
  382. }
  383. }
  384. function closeEdgeListModal() {
  385. edgeListModal.value = {
  386. show: false,
  387. type: 'in',
  388. edges: []
  389. }
  390. }
  391. // 复制JSON到剪贴板(详情)
  392. function copyJson() {
  393. const data = store.selectedNode || store.selectedEdge
  394. if (!data) return
  395. navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
  396. copySuccess.value = true
  397. setTimeout(() => { copySuccess.value = false }, 1500)
  398. })
  399. }
  400. // 当前选中的帖子索引
  401. const selectedPostIdx = ref(store.selectedPostIndex)
  402. // 匹配边列表(按分数从高到低排序,去重,无分数的放最后)
  403. const sortedMatchEdges = computed(() => {
  404. const postGraph = store.currentPostGraph
  405. if (!postGraph?.edges) return []
  406. const matchEdges = []
  407. const seen = new Set() // 用于去重
  408. for (const [edgeId, edge] of Object.entries(postGraph.edges)) {
  409. if (edge.type === '匹配') {
  410. // 生成去重key(两个节点排序后拼接,确保A-B和B-A生成相同的key)
  411. const pairKey = [edge.source, edge.target].sort().join('|')
  412. if (seen.has(pairKey)) continue
  413. seen.add(pairKey)
  414. // 获取源节点和目标节点名称
  415. const sourceNode = postGraph.nodes?.[edge.source]
  416. const targetNode = store.getNode(edge.target) // 目标是人设节点
  417. matchEdges.push({
  418. ...edge,
  419. sourceName: sourceNode?.name || edge.source.split(':').pop(),
  420. targetName: targetNode?.name || edge.target.split(':').pop()
  421. })
  422. }
  423. }
  424. // 按分数从高到低排序,无分数的放最后
  425. return matchEdges.sort((a, b) => {
  426. const aScore = a.score ?? -Infinity
  427. const bScore = b.score ?? -Infinity
  428. return bScore - aScore
  429. })
  430. })
  431. // 获取匹配边的分数颜色
  432. function getScoreColor(score) {
  433. return getEdgeStyle({ type: '匹配', score }).color
  434. }
  435. // 获取源节点颜色(帖子域节点)
  436. function getSourceNodeColor(edge) {
  437. const postGraph = store.currentPostGraph
  438. const sourceNode = postGraph?.nodes?.[edge.source]
  439. if (sourceNode?.dimension) {
  440. return dimColors[sourceNode.dimension] || '#888'
  441. }
  442. return '#888'
  443. }
  444. // 获取目标节点颜色(人设域节点)
  445. function getTargetNodeColor(edge) {
  446. const targetNode = store.getNode(edge.target)
  447. if (targetNode?.dimension) {
  448. return dimColors[targetNode.dimension] || '#888'
  449. }
  450. return '#888'
  451. }
  452. // 显示的节点(hover 优先于 selected)
  453. const displayNode = computed(() => store.hoverNode || store.selectedNode)
  454. // 显示节点的样式
  455. const displayNodeStyle = computed(() => {
  456. if (!displayNode.value) return { color: '#888', shape: 'circle', hollow: false }
  457. return getNodeStyle(displayNode.value)
  458. })
  459. // 选中节点的样式(兼容旧代码)
  460. const selectedNodeStyle = computed(() => {
  461. if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
  462. return getNodeStyle(store.selectedNode)
  463. })
  464. // 选中节点的颜色(兼容)
  465. const selectedNodeColor = computed(() => selectedNodeStyle.value.color)
  466. // 选中边的颜色
  467. const selectedEdgeColor = computed(() => {
  468. if (!store.selectedEdge) return '#888'
  469. return edgeTypeColors[store.selectedEdge.type] || '#888'
  470. })
  471. // 显示的边(hover 优先于 selected)
  472. const displayEdge = computed(() => {
  473. return store.hoverEdgeData || store.selectedEdge
  474. })
  475. // 显示的边颜色
  476. const displayEdgeColor = computed(() => {
  477. if (!displayEdge.value) return '#888'
  478. return edgeTypeColors[displayEdge.value.type] || '#888'
  479. })
  480. // 节点的入边列表(按分数降序)
  481. const nodeInEdges = computed(() => {
  482. if (!displayNode.value) return []
  483. const nodeId = displayNode.value.id || displayNode.value.data?.id
  484. if (!nodeId) return []
  485. const postGraph = store.currentPostGraph
  486. if (!postGraph?.edges) return []
  487. return Object.values(postGraph.edges)
  488. .filter(e => e.target === nodeId)
  489. .sort((a, b) => (b.score || 0) - (a.score || 0))
  490. })
  491. // 节点的出边列表(按分数降序)
  492. const nodeOutEdges = computed(() => {
  493. if (!displayNode.value) return []
  494. const nodeId = displayNode.value.id || displayNode.value.data?.id
  495. if (!nodeId) return []
  496. const postGraph = store.currentPostGraph
  497. if (!postGraph?.edges) return []
  498. return Object.values(postGraph.edges)
  499. .filter(e => e.source === nodeId)
  500. .sort((a, b) => (b.score || 0) - (a.score || 0))
  501. })
  502. // 获取节点名称(根据节点ID)
  503. function getNodeName(nodeId) {
  504. if (!nodeId) return '-'
  505. // 先从当前帖子图中查找
  506. const postGraph = store.currentPostGraph
  507. if (postGraph?.nodes?.[nodeId]) {
  508. return postGraph.nodes[nodeId].name || nodeId.split(':').pop()
  509. }
  510. // 再从人设节点中查找
  511. const personaNode = store.getNode(nodeId)
  512. if (personaNode) {
  513. return personaNode.name || nodeId.split(':').pop()
  514. }
  515. // 回退到从ID提取名称
  516. return nodeId.split(':').pop() || nodeId
  517. }
  518. // 获取边ID
  519. function getEdgeId(edge) {
  520. return `${edge.source}|${edge.type}|${edge.target}`
  521. }
  522. // 点击匹配项
  523. function onMatchClick(edge) {
  524. store.selectEdge({
  525. source: edge.source,
  526. target: edge.target,
  527. type: edge.type,
  528. score: edge.score
  529. })
  530. }
  531. // zoom 实例和主 g 元素
  532. let zoom = null
  533. let mainG = null
  534. let treeWidth = 0
  535. let treeHeight = 0
  536. // 选择帖子
  537. function selectPost(index) {
  538. selectedPostIdx.value = index
  539. store.selectPost(index)
  540. }
  541. // 格式化帖子标题(简短显示)
  542. function formatPostTitle(post) {
  543. const title = post.postTitle || post.postId
  544. return title.length > 10 ? title.slice(0, 10) + '…' : title
  545. }
  546. // 格式化帖子选项显示(完整)
  547. function formatPostOption(post) {
  548. const date = post.createTime ? new Date(post.createTime * 1000).toLocaleDateString() : ''
  549. const title = post.postTitle || post.postId
  550. const shortTitle = title.length > 20 ? title.slice(0, 20) + '...' : title
  551. return date ? `${date} ${shortTitle}` : shortTitle
  552. }
  553. // 节点元素映射(统一存储所有节点位置)
  554. let nodeElements = {}
  555. let baseNodeElements = {} // 基础节点(帖子树+匹配层),不含游走节点
  556. let currentRoot = null
  557. // 处理节点点击
  558. function handleNodeClick(event, d) {
  559. event.stopPropagation()
  560. // 锁定状态下点击节点无效果,但提醒用户
  561. if (store.lockedHoverNodeId) {
  562. shakeLockButton()
  563. return
  564. }
  565. store.selectNode(d)
  566. }
  567. // 渲染树
  568. function renderTree() {
  569. const svg = d3.select(svgRef.value)
  570. svg.selectAll('*').remove()
  571. nodeElements = {}
  572. const treeData = store.postTreeData
  573. if (!treeData || !treeData.id) return
  574. const container = containerRef.value
  575. if (!container) return
  576. const width = container.clientWidth
  577. const height = container.clientHeight
  578. svg.attr('viewBox', `0 0 ${width} ${height}`)
  579. // 创建层级数据
  580. const root = d3.hierarchy(treeData)
  581. currentRoot = root
  582. // 智能计算树的尺寸(垂直布局)
  583. const allNodes = root.descendants()
  584. const maxDepth = d3.max(allNodes, d => d.depth)
  585. const leafCount = allNodes.filter(d => !d.children).length
  586. // 计算最长文字长度(用于动态调整间距)
  587. const maxTextLen = d3.max(allNodes, d => {
  588. const name = d.data.name || ''
  589. return Math.min(name.length, 10) // 最多显示10个字
  590. }) || 6
  591. // 动态计算节点间距(根据文字长度)
  592. const nodeSpacing = Math.max(60, maxTextLen * 12)
  593. // 宽度:基于叶子节点数量和文字间距
  594. treeWidth = Math.max(400, leafCount * nodeSpacing + 100)
  595. // 高度:根据深度,增大层间距避免垂直重叠
  596. treeHeight = Math.max(400, (maxDepth + 1) * 120 + 50)
  597. // 创建缩放行为
  598. zoom = d3.zoom()
  599. .scaleExtent([0.1, 3])
  600. .on('zoom', (e) => {
  601. mainG.attr('transform', e.transform)
  602. })
  603. svg.call(zoom)
  604. // 创建主组
  605. mainG = svg.append('g')
  606. // 创建树布局(垂直方向:从上到下)
  607. const treeLayout = d3.tree()
  608. .size([treeWidth - 100, treeHeight - 50])
  609. .separation((a, b) => {
  610. // 同级节点间距根据是否有子节点调整
  611. if (a.parent === b.parent) {
  612. // 叶子节点需要更大间距放文字
  613. const aIsLeaf = !a.children
  614. const bIsLeaf = !b.children
  615. if (aIsLeaf || bIsLeaf) return 1.5
  616. return 1
  617. }
  618. return 2
  619. })
  620. treeLayout(root)
  621. // 内容组(带偏移)
  622. const contentG = mainG.append('g')
  623. .attr('transform', 'translate(50, 25)')
  624. // 绘制边(垂直方向)
  625. contentG.append('g')
  626. .attr('class', 'tree-edges')
  627. .selectAll('.tree-link')
  628. .data(root.links())
  629. .join('path')
  630. .attr('class', 'tree-link')
  631. .attr('fill', 'none')
  632. .attr('stroke', '#3498db')
  633. .attr('stroke-opacity', 0.3)
  634. .attr('stroke-width', 1)
  635. .attr('d', d => {
  636. const midY = (d.source.y + d.target.y) / 2
  637. return `M${d.source.x},${d.source.y} C${d.source.x},${midY} ${d.target.x},${midY} ${d.target.x},${d.target.y}`
  638. })
  639. // 绘制节点(垂直布局:x 是水平位置,y 是垂直位置)
  640. const nodes = contentG.append('g')
  641. .attr('class', 'tree-nodes')
  642. .selectAll('.tree-node')
  643. .data(root.descendants())
  644. .join('g')
  645. .attr('class', 'tree-node')
  646. .attr('transform', d => `translate(${d.x},${d.y})`)
  647. .style('cursor', 'pointer')
  648. .on('click', handleNodeClick)
  649. // 节点形状(使用统一配置)
  650. nodes.each(function(d) {
  651. const el = d3.select(this)
  652. // 帖子树节点属于帖子域(空心)
  653. d.data.domain = '帖子'
  654. const style = getNodeStyle(d)
  655. applyNodeShape(el, style).attr('class', 'tree-shape')
  656. nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
  657. })
  658. // 节点标签(使用统一配置)
  659. nodes.append('text')
  660. .attr('dy', d => d.children ? -10 : 4)
  661. .attr('dx', d => d.children ? 0 : 10)
  662. .attr('text-anchor', d => d.children ? 'middle' : 'start')
  663. .attr('fill', d => getNodeStyle(d).text.fill)
  664. .attr('font-size', d => getNodeStyle(d).text.fontSize)
  665. .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
  666. .text(d => {
  667. const name = d.data.name
  668. const maxLen = 10
  669. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  670. })
  671. // ========== 绘制匹配层 ==========
  672. renderMatchLayer(contentG, root, treeHeight)
  673. // 初始适应视图
  674. fitToView()
  675. // 设置 hover 处理器(需要在所有元素创建后)
  676. nextTick(() => setupHoverHandlers())
  677. }
  678. // 绘制匹配层(人设节点 + 连线)
  679. function renderMatchLayer(contentG, root, baseTreeHeight) {
  680. const postGraph = store.currentPostGraph
  681. if (!postGraph || !postGraph.edges) return
  682. // 提取匹配边(只取帖子->人设方向的)
  683. const matchEdges = []
  684. for (const edge of Object.values(postGraph.edges)) {
  685. if (edge.type === '匹配' && edge.source.startsWith('帖子:') && edge.target.startsWith('人设:')) {
  686. matchEdges.push(edge)
  687. }
  688. }
  689. if (matchEdges.length === 0) return
  690. // 收集匹配的人设节点(去重)
  691. const matchedPersonaMap = new Map()
  692. for (const edge of matchEdges) {
  693. if (!matchedPersonaMap.has(edge.target)) {
  694. // 从人设节点ID提取信息: "人设:目的点:标签:进行产品种草"
  695. const parts = edge.target.split(':')
  696. const name = parts[parts.length - 1]
  697. const dimension = parts[1] // 灵感点/目的点/关键点
  698. const type = parts[2] // 标签/分类/点
  699. matchedPersonaMap.set(edge.target, {
  700. id: edge.target,
  701. name: name,
  702. dimension: dimension,
  703. type: type,
  704. domain: '人设', // 人设节点:实心
  705. sourceEdges: [] // 连接的帖子节点
  706. })
  707. }
  708. matchedPersonaMap.get(edge.target).sourceEdges.push({
  709. sourceId: edge.source,
  710. score: edge.score
  711. })
  712. }
  713. const matchedPersonas = Array.from(matchedPersonaMap.values())
  714. if (matchedPersonas.length === 0) return
  715. // 计算匹配层的 Y 位置(树的最大深度 + 间距)
  716. const maxY = d3.max(root.descendants(), d => d.y) || 0
  717. const matchLayerY = maxY + 100
  718. // 计算匹配节点的 X 位置(均匀分布)
  719. const minX = d3.min(root.descendants(), d => d.x) || 0
  720. const maxX = d3.max(root.descendants(), d => d.x) || 0
  721. const matchSpacing = (maxX - minX) / Math.max(matchedPersonas.length - 1, 1)
  722. matchedPersonas.forEach((persona, i) => {
  723. persona.x = matchedPersonas.length === 1
  724. ? (minX + maxX) / 2
  725. : minX + i * matchSpacing
  726. persona.y = matchLayerY
  727. })
  728. // 收集匹配边数据(统一数据结构:source, target)
  729. const matchLinksData = []
  730. for (const persona of matchedPersonas) {
  731. for (const srcEdge of persona.sourceEdges) {
  732. const sourceNode = nodeElements[srcEdge.sourceId]
  733. if (!sourceNode) continue
  734. matchLinksData.push({
  735. source: srcEdge.sourceId,
  736. target: persona.id,
  737. score: srcEdge.score,
  738. srcX: sourceNode.x - 50,
  739. srcY: sourceNode.y - 25,
  740. tgtX: persona.x,
  741. tgtY: persona.y
  742. })
  743. }
  744. }
  745. // 绘制匹配连线(使用 data binding)
  746. const matchLinksG = contentG.append('g').attr('class', 'match-links')
  747. matchLinksG.selectAll('.match-link')
  748. .data(matchLinksData)
  749. .join('path')
  750. .attr('class', 'match-link')
  751. .attr('fill', 'none')
  752. .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
  753. .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
  754. .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
  755. .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
  756. .style('cursor', 'pointer')
  757. .on('click', (e, d) => {
  758. e.stopPropagation()
  759. store.selectEdge({
  760. source: d.source,
  761. target: d.target,
  762. type: '匹配',
  763. score: d.score
  764. })
  765. })
  766. .attr('d', d => {
  767. const midY = (d.srcY + d.tgtY) / 2
  768. return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
  769. })
  770. // 绘制分数标签(使用 data binding)
  771. const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
  772. const scoreGroups = matchLinksG.selectAll('.match-score')
  773. .data(scoreData)
  774. .join('g')
  775. .attr('class', 'match-score')
  776. .attr('transform', d => {
  777. const midX = (d.srcX + d.tgtX) / 2
  778. const midY = (d.srcY + d.tgtY) / 2
  779. return `translate(${midX}, ${midY})`
  780. })
  781. scoreGroups.append('rect')
  782. .attr('x', -14)
  783. .attr('y', -6)
  784. .attr('width', 28)
  785. .attr('height', 12)
  786. .attr('rx', 2)
  787. .attr('fill', '#1d232a')
  788. .attr('opacity', 0.9)
  789. scoreGroups.append('text')
  790. .attr('text-anchor', 'middle')
  791. .attr('dy', '0.35em')
  792. .attr('fill', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
  793. .attr('font-size', '8px')
  794. .text(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
  795. // 绘制匹配节点
  796. const matchNodesG = contentG.append('g').attr('class', 'match-nodes')
  797. const matchNodes = matchNodesG.selectAll('.match-node')
  798. .data(matchedPersonas)
  799. .join('g')
  800. .attr('class', 'match-node')
  801. .attr('transform', d => `translate(${d.x},${d.y})`)
  802. .style('cursor', 'pointer')
  803. .on('click', handleMatchNodeClick)
  804. // 匹配节点形状(使用统一配置)
  805. matchNodes.each(function(d) {
  806. const el = d3.select(this)
  807. const style = getNodeStyle(d, { isMatch: true })
  808. applyNodeShape(el, style).attr('class', 'tree-shape')
  809. nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
  810. })
  811. // 匹配节点标签(使用统一配置)
  812. matchNodes.append('text')
  813. .attr('dy', 4)
  814. .attr('dx', 10)
  815. .attr('text-anchor', 'start')
  816. .attr('fill', d => getNodeStyle(d, { isMatch: true }).text.fill)
  817. .attr('font-size', d => getNodeStyle(d, { isMatch: true }).text.fontSize)
  818. .attr('font-weight', d => getNodeStyle(d, { isMatch: true }).text.fontWeight)
  819. .text(d => {
  820. const name = d.name
  821. const maxLen = 10
  822. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  823. })
  824. // 更新总高度(用于 fitToView)
  825. treeHeight = matchLayerY + 50
  826. // 保存匹配层 Y 位置,供游走层使用
  827. lastMatchLayerY = matchLayerY
  828. // 保存基础节点快照(帖子树+匹配层),供游走层判断已有节点
  829. baseNodeElements = { ...nodeElements }
  830. }
  831. // 保存匹配层 Y 位置
  832. let lastMatchLayerY = 0
  833. // 绘制游走层(按层级渲染路径)
  834. function renderWalkedLayer() {
  835. if (!mainG) return
  836. // 移除旧的游走层
  837. mainG.selectAll('.walked-layer').remove()
  838. // 重置 nodeElements 为基础节点(清除之前的游走节点)
  839. nodeElements = { ...baseNodeElements }
  840. const paths = store.postWalkedPaths
  841. if (!paths.length) return
  842. const contentG = mainG.select('g')
  843. if (contentG.empty()) return
  844. // 创建游走层组
  845. const walkedG = contentG.append('g').attr('class', 'walked-layer')
  846. // ========== 按路径位置分层遍历,确定每个新节点的层数 ==========
  847. // nodeLayer: nodeId -> layer (已有节点从 nodeElements 推断,新节点计算得出)
  848. const nodeLayer = new Map()
  849. const newNodes = new Set() // 新增的节点
  850. console.log('=== renderWalkedLayer 层数计算 ===')
  851. console.log('路径数:', paths.length)
  852. console.log('lastMatchLayerY:', lastMatchLayerY)
  853. // 初始化:已有节点的层数(帖子树和匹配层)
  854. // 使用 baseNodeElements(不含之前的游走节点)
  855. // 匹配层的 Y 坐标是 lastMatchLayerY,作为基准层 0
  856. for (const [nodeId, info] of Object.entries(baseNodeElements)) {
  857. // 根据 Y 坐标推断层数(匹配层为0,帖子树在上面为负数)
  858. const layer = Math.round((info.y - 25 - lastMatchLayerY) / 80)
  859. nodeLayer.set(nodeId, Math.min(layer, 0)) // 已有节点层数 <= 0
  860. }
  861. console.log('基础节点数:', nodeLayer.size)
  862. // 找出所有路径的最大长度
  863. const maxPathLen = Math.max(...paths.map(p => p.nodes.length))
  864. console.log('最大路径长度:', maxPathLen)
  865. // 按位置分层遍历(从第2个节点开始,index=1)
  866. for (let pos = 1; pos < maxPathLen; pos++) {
  867. console.log(`--- 处理位置 ${pos} ---`)
  868. for (const path of paths) {
  869. if (pos >= path.nodes.length) continue
  870. const nodeId = path.nodes[pos]
  871. const prevNodeId = path.nodes[pos - 1]
  872. // 如果已经在树上,跳过
  873. if (nodeLayer.has(nodeId)) {
  874. console.log(` 节点 ${nodeId}: 已存在,层数=${nodeLayer.get(nodeId)},跳过`)
  875. continue
  876. }
  877. // 找前驱节点的层数
  878. const prevLayer = nodeLayer.get(prevNodeId) ?? 0
  879. const newLayer = prevLayer + 1
  880. console.log(` 节点 ${nodeId}: 前驱=${prevNodeId}(层${prevLayer}) → 新层数=${newLayer}`)
  881. // 新节点层数 = 前驱层数 + 1
  882. nodeLayer.set(nodeId, newLayer)
  883. newNodes.add(nodeId)
  884. }
  885. }
  886. console.log('新增节点数:', newNodes.size)
  887. console.log('新增节点:', Array.from(newNodes))
  888. // ========== 按层分组新节点 ==========
  889. const layerGroups = new Map() // layer -> Set of nodeIds
  890. for (const nodeId of newNodes) {
  891. const layer = nodeLayer.get(nodeId)
  892. if (!layerGroups.has(layer)) {
  893. layerGroups.set(layer, new Set())
  894. }
  895. layerGroups.get(layer).add(nodeId)
  896. }
  897. // ========== 计算新节点位置 ==========
  898. const treeNodes = Object.values(nodeElements)
  899. const minX = d3.min(treeNodes, d => d.x) || 50
  900. const maxX = d3.max(treeNodes, d => d.x) || 300
  901. const layerSpacing = 80 // 层间距
  902. // nodePositions 只存储新节点的位置
  903. const nodePositions = {}
  904. for (const [layer, nodeIds] of layerGroups) {
  905. const nodesAtLayer = Array.from(nodeIds)
  906. const layerY = lastMatchLayerY + layer * layerSpacing
  907. // 新节点均匀分布
  908. const spacing = (maxX - minX - 100) / Math.max(nodesAtLayer.length - 1, 1)
  909. nodesAtLayer.forEach((nodeId, i) => {
  910. nodePositions[nodeId] = {
  911. x: nodesAtLayer.length === 1 ? (minX + maxX) / 2 - 50 : (minX - 50) + i * spacing,
  912. y: layerY
  913. }
  914. })
  915. }
  916. // ========== 收集所有边 ==========
  917. const allEdges = new Map()
  918. for (const path of paths) {
  919. for (const edge of path.edges) {
  920. const edgeKey = `${edge.source}->${edge.target}`
  921. if (!allEdges.has(edgeKey)) {
  922. allEdges.set(edgeKey, edge)
  923. }
  924. }
  925. }
  926. // 获取节点位置的辅助函数(优先新节点位置,然后已有节点位置)
  927. function getNodePos(nodeId) {
  928. if (nodePositions[nodeId]) {
  929. return nodePositions[nodeId]
  930. }
  931. if (nodeElements[nodeId]) {
  932. return {
  933. x: nodeElements[nodeId].x - 50,
  934. y: nodeElements[nodeId].y - 25
  935. }
  936. }
  937. return null
  938. }
  939. // ========== 绘制所有路径上的边 ==========
  940. const edgesData = []
  941. console.log('=== 绘制边 ===')
  942. console.log('总边数:', allEdges.size)
  943. for (const edge of allEdges.values()) {
  944. const srcPos = getNodePos(edge.source)
  945. const tgtPos = getNodePos(edge.target)
  946. console.log(`边 ${edge.source} -> ${edge.target}:`,
  947. 'srcPos=', srcPos ? `(${srcPos.x.toFixed(0)},${srcPos.y.toFixed(0)})` : 'null',
  948. 'tgtPos=', tgtPos ? `(${tgtPos.x.toFixed(0)},${tgtPos.y.toFixed(0)})` : 'null'
  949. )
  950. if (srcPos && tgtPos) {
  951. edgesData.push({
  952. ...edge,
  953. srcX: srcPos.x,
  954. srcY: srcPos.y,
  955. tgtX: tgtPos.x,
  956. tgtY: tgtPos.y
  957. })
  958. } else {
  959. console.log(' ⚠️ 跳过:位置缺失')
  960. }
  961. }
  962. console.log('实际绘制边数:', edgesData.length)
  963. walkedG.selectAll('.walked-link')
  964. .data(edgesData)
  965. .join('path')
  966. .attr('class', 'walked-link')
  967. .attr('fill', 'none')
  968. .attr('stroke', d => getEdgeStyle({ type: d.type, score: d.score }).color)
  969. .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }).opacity)
  970. .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
  971. .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
  972. .style('cursor', 'pointer')
  973. .on('click', (e, d) => {
  974. e.stopPropagation()
  975. store.selectEdge({
  976. source: d.source,
  977. target: d.target,
  978. type: d.type,
  979. score: d.score
  980. })
  981. })
  982. .attr('d', d => {
  983. // 同一层的边(Y坐标相近)用向下弯曲的曲线
  984. if (Math.abs(d.srcY - d.tgtY) < 10) {
  985. const controlY = d.srcY + 50 // 向下弯曲
  986. const midX = (d.srcX + d.tgtX) / 2
  987. return `M${d.srcX},${d.srcY} Q${midX},${controlY} ${d.tgtX},${d.tgtY}`
  988. }
  989. // 不同层的边用 S 形曲线
  990. const midY = (d.srcY + d.tgtY) / 2
  991. return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
  992. })
  993. // 绘制分数标签
  994. const scoreData = edgesData.filter(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
  995. const scoreGroups = walkedG.selectAll('.walked-score')
  996. .data(scoreData)
  997. .join('g')
  998. .attr('class', 'walked-score')
  999. .attr('transform', d => {
  1000. const midX = (d.srcX + d.tgtX) / 2
  1001. // 同一层的边用向下弯曲曲线,分数标签放在曲线中点(t=0.5 时 y = srcY + 25)
  1002. const midY = Math.abs(d.srcY - d.tgtY) < 10 ? d.srcY + 25 : (d.srcY + d.tgtY) / 2
  1003. return `translate(${midX}, ${midY})`
  1004. })
  1005. scoreGroups.append('rect')
  1006. .attr('x', -14).attr('y', -6).attr('width', 28).attr('height', 12)
  1007. .attr('rx', 2).attr('fill', '#1d232a').attr('opacity', 0.9)
  1008. scoreGroups.append('text')
  1009. .attr('text-anchor', 'middle').attr('dy', '0.35em')
  1010. .attr('fill', d => getEdgeStyle({ type: d.type, score: d.score }).color)
  1011. .attr('font-size', '8px')
  1012. .text(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
  1013. // ========== 绘制新节点(nodePositions 中的都是新节点) ==========
  1014. const newNodesData = []
  1015. for (const [nodeId, pos] of Object.entries(nodePositions)) {
  1016. // 获取节点数据
  1017. const nodeData = store.postWalkedNodes.find(n => n.id === nodeId)
  1018. if (nodeData) {
  1019. newNodesData.push({ ...nodeData, x: pos.x, y: pos.y })
  1020. }
  1021. }
  1022. const walkedNodeGroups = walkedG.selectAll('.walked-node')
  1023. .data(newNodesData)
  1024. .join('g')
  1025. .attr('class', 'walked-node')
  1026. .attr('transform', d => `translate(${d.x},${d.y})`)
  1027. .style('cursor', 'pointer')
  1028. .on('click', (event, d) => {
  1029. event.stopPropagation()
  1030. // 锁定状态下点击节点无效果,但提醒用户
  1031. if (store.lockedHoverNodeId) {
  1032. shakeLockButton()
  1033. return
  1034. }
  1035. store.selectNode(d)
  1036. })
  1037. // 节点形状
  1038. walkedNodeGroups.each(function(d) {
  1039. const el = d3.select(this)
  1040. const style = getNodeStyle(d)
  1041. applyNodeShape(el, style).attr('class', 'walked-shape')
  1042. nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
  1043. })
  1044. // 节点标签
  1045. walkedNodeGroups.append('text')
  1046. .attr('dy', 4).attr('dx', 10).attr('text-anchor', 'start')
  1047. .attr('fill', d => getNodeStyle(d).text.fill)
  1048. .attr('font-size', d => getNodeStyle(d).text.fontSize)
  1049. .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
  1050. .text(d => {
  1051. const name = d.name
  1052. const maxLen = 10
  1053. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  1054. })
  1055. // 更新高度(如果有新节点)
  1056. if (Object.keys(nodePositions).length > 0) {
  1057. const maxY = Math.max(...Object.values(nodePositions).map(p => p.y))
  1058. treeHeight = Math.max(treeHeight, maxY + 50)
  1059. }
  1060. // ========== 添加 hover 效果(所有元素创建后统一添加) ==========
  1061. setupHoverHandlers()
  1062. }
  1063. // 在节点文字后面添加锁定按钮(作为独立的 tspan)
  1064. function showLockButton(nodeEl, immediate = false) {
  1065. if (!nodeEl) return
  1066. const node = d3.select(nodeEl)
  1067. const textEl = node.select('text')
  1068. if (textEl.empty()) return
  1069. // 获取当前节点 ID
  1070. const nodeData = node.datum()
  1071. const currentNodeId = nodeData?.data?.id || nodeData?.id
  1072. // 判断当前节点是否是已锁定的节点
  1073. const isThisNodeLocked = store.lockedHoverNodeId && store.lockedHoverNodeId === currentNodeId
  1074. // 如果已有按钮,只更新状态
  1075. let btn = textEl.select('.lock-btn')
  1076. if (!btn.empty()) {
  1077. btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
  1078. .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
  1079. if (!isThisNodeLocked) startBreathingAnimation(btn)
  1080. return
  1081. }
  1082. // 创建按钮的函数
  1083. const createBtn = () => {
  1084. // 先清除当前 SVG 内其他节点的按钮(不影响另一边)
  1085. if (svgRef.value) {
  1086. d3.select(svgRef.value).selectAll('.lock-btn').remove()
  1087. }
  1088. // 添加按钮 tspan(紧跟在文字后面)
  1089. const btn = textEl.append('tspan')
  1090. .attr('class', 'lock-btn')
  1091. .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
  1092. .attr('font-weight', 'bold')
  1093. .style('cursor', 'pointer')
  1094. .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
  1095. .on('click', (e) => {
  1096. e.stopPropagation()
  1097. handleLockClick()
  1098. })
  1099. // 呼吸灯动画(未锁定时)
  1100. if (!isThisNodeLocked) {
  1101. startBreathingAnimation(btn)
  1102. }
  1103. }
  1104. createBtn()
  1105. }
  1106. // 呼吸灯动画(只有按钮部分,蓝色呼吸)
  1107. function startBreathingAnimation(btn) {
  1108. function breathe() {
  1109. if (btn.empty() || !btn.node()) return
  1110. if (store.lockedHoverNodeId) return
  1111. btn
  1112. .transition()
  1113. .duration(800)
  1114. .attr('fill', '#90cdf4')
  1115. .transition()
  1116. .duration(800)
  1117. .attr('fill', '#63b3ed')
  1118. .on('end', breathe)
  1119. }
  1120. breathe()
  1121. }
  1122. // 隐藏锁定按钮
  1123. function hideLockButton() {
  1124. if (svgRef.value) {
  1125. d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
  1126. }
  1127. }
  1128. // 抖动锁定按钮(提醒用户需要先解锁)
  1129. function shakeLockButton() {
  1130. d3.selectAll('.lock-btn')
  1131. .interrupt()
  1132. .attr('fill', '#fc8181') // 红色警告
  1133. .transition().duration(50).attr('dx', 3)
  1134. .transition().duration(50).attr('dx', -3)
  1135. .transition().duration(50).attr('dx', 3)
  1136. .transition().duration(50).attr('dx', -3)
  1137. .transition().duration(50).attr('dx', 0)
  1138. .transition().duration(200).attr('fill', '#f6ad55') // 恢复橙色
  1139. }
  1140. // 处理锁定按钮点击
  1141. function handleLockClick() {
  1142. const startNodeId = store.selectedNodeId
  1143. const currentHoverNodeId = store.hoverNodeId
  1144. // 判断是解锁还是新锁定
  1145. if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
  1146. // 点击的是当前锁定节点的按钮 → 解锁(弹出栈)
  1147. store.clearLockedHover()
  1148. // 如果还有上一层锁定,更新按钮状态
  1149. if (store.lockedHoverNodeId) {
  1150. d3.selectAll('.lock-btn')
  1151. .interrupt()
  1152. .text(' 🔓解锁')
  1153. .attr('fill', '#f6ad55')
  1154. } else {
  1155. // 完全解锁,清除按钮
  1156. d3.selectAll('.lock-btn').interrupt().remove()
  1157. }
  1158. } else if (currentHoverNodeId) {
  1159. // 点击的是新 hover 节点的按钮 → 锁定新路径(压入栈)
  1160. store.lockCurrentHover(startNodeId)
  1161. // 更新按钮状态
  1162. d3.selectAll('.lock-btn')
  1163. .interrupt()
  1164. .text(' 🔓解锁')
  1165. .attr('fill', '#f6ad55')
  1166. }
  1167. }
  1168. // 设置 hover 处理器(在所有元素创建后调用)
  1169. function setupHoverHandlers() {
  1170. if (!svgRef.value || !store.selectedNodeId) return
  1171. const svg = d3.select(svgRef.value)
  1172. const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
  1173. const startNodeId = store.selectedNodeId
  1174. // 如果已有锁定状态,显示解锁按钮
  1175. if (store.lockedHoverNodeId) {
  1176. const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
  1177. if (lockedNodeInfo?.element) {
  1178. showLockButton(lockedNodeInfo.element, true)
  1179. }
  1180. }
  1181. // 添加 hover 处理器(路径计算由 store 统一处理)
  1182. allNodes
  1183. .on('mouseenter', function(event, d) {
  1184. const nodeId = d.data?.id || d.id
  1185. // 排除起始节点
  1186. if (nodeId === startNodeId) return
  1187. store.computeHoverPath(startNodeId, nodeId, 'post-tree')
  1188. store.setHoverNode(d) // 设置 hover 节点数据用于详情显示
  1189. // 显示锁定按钮(在当前hover节点上)
  1190. if (store.hoverPathNodes.size > 0) {
  1191. // 如果已锁定,按钮显示在锁定节点上
  1192. if (store.lockedHoverNodeId) {
  1193. const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
  1194. if (lockedNodeInfo?.element) {
  1195. showLockButton(lockedNodeInfo.element, true)
  1196. }
  1197. } else {
  1198. // 未锁定,按钮显示在当前hover节点上
  1199. showLockButton(this)
  1200. }
  1201. }
  1202. })
  1203. .on('mouseleave', () => {
  1204. // 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
  1205. store.clearHover()
  1206. if (store.lockedHoverNodeId) {
  1207. // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
  1208. const svg = d3.select(svgRef.value)
  1209. const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
  1210. const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
  1211. const allLabels = svg.selectAll('.match-score, .walked-score')
  1212. // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
  1213. applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, null)
  1214. // 在锁定节点上显示解锁按钮
  1215. const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
  1216. if (lockedNodeInfo?.element) {
  1217. showLockButton(lockedNodeInfo.element, true)
  1218. }
  1219. } else {
  1220. hideLockButton()
  1221. }
  1222. })
  1223. }
  1224. // 匹配节点点击处理
  1225. function handleMatchNodeClick(event, d) {
  1226. event.stopPropagation()
  1227. // 锁定状态下点击节点无效果,但提醒用户
  1228. if (store.lockedHoverNodeId) {
  1229. shakeLockButton()
  1230. return
  1231. }
  1232. store.selectNode(d)
  1233. }
  1234. // ========== 详情显示格式化函数 ==========
  1235. // 格式化字段名(camelCase/snake_case -> 中文/可读)
  1236. function formatKey(key) {
  1237. const keyMap = {
  1238. 'id': 'ID',
  1239. 'name': '名称',
  1240. 'type': '类型',
  1241. 'dimension': '维度',
  1242. 'source': '源节点',
  1243. 'target': '目标节点',
  1244. 'score': '分数',
  1245. 'detail': '详情',
  1246. 'postId': '帖子ID',
  1247. 'postTitle': '帖子标题',
  1248. 'createTime': '创建时间',
  1249. 'parentId': '父节点',
  1250. 'children': '子节点',
  1251. 'description': '描述',
  1252. 'content': '内容',
  1253. 'tags': '标签',
  1254. 'category': '分类',
  1255. 'level': '层级',
  1256. 'depth': '深度',
  1257. 'weight': '权重',
  1258. 'count': '数量',
  1259. 'status': '状态',
  1260. 'reason': '原因',
  1261. 'explanation': '说明',
  1262. 'matchReason': '匹配原因',
  1263. 'similarity': '相似度',
  1264. 'confidence': '置信度'
  1265. }
  1266. return keyMap[key] || key
  1267. }
  1268. // 格式化普通值
  1269. function formatValue(value) {
  1270. if (value === null || value === undefined) return '-'
  1271. if (typeof value === 'boolean') return value ? '是' : '否'
  1272. if (typeof value === 'number') {
  1273. // 如果是小数,保留2位
  1274. if (!Number.isInteger(value)) return value.toFixed(2)
  1275. return value.toString()
  1276. }
  1277. if (Array.isArray(value)) {
  1278. if (value.length === 0) return '-'
  1279. return value.join(', ')
  1280. }
  1281. if (typeof value === 'object') {
  1282. return JSON.stringify(value)
  1283. }
  1284. return String(value)
  1285. }
  1286. // 格式化边的值(特殊处理 source/target 显示名称)
  1287. function formatEdgeValue(key, value) {
  1288. if (key === 'source' || key === 'target') {
  1289. // 使用 getNodeName 获取节点名称
  1290. return getNodeName(value)
  1291. }
  1292. return formatValue(value)
  1293. }
  1294. // 适应视图(自动缩放以显示全部内容)
  1295. function fitToView() {
  1296. if (!zoom || !mainG || !containerRef.value) return
  1297. const container = containerRef.value
  1298. const width = container.clientWidth
  1299. const height = container.clientHeight
  1300. // 计算缩放比例以适应容器
  1301. const scaleX = width / treeWidth
  1302. const scaleY = height / treeHeight
  1303. const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
  1304. // 计算居中偏移
  1305. const translateX = (width - treeWidth * scale) / 2
  1306. const translateY = (height - treeHeight * scale) / 2
  1307. const svg = d3.select(svgRef.value)
  1308. svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
  1309. }
  1310. // 定位到指定节点
  1311. function zoomToNode(nodeId) {
  1312. const nodeInfo = nodeElements[nodeId]
  1313. if (!nodeInfo || !zoom || !containerRef.value) return
  1314. const container = containerRef.value
  1315. const width = container.clientWidth
  1316. const height = container.clientHeight
  1317. // 计算平移使节点居中
  1318. const scale = 1
  1319. const translateX = width / 2 - nodeInfo.x * scale
  1320. const translateY = height / 2 - nodeInfo.y * scale
  1321. const svg = d3.select(svgRef.value)
  1322. svg.transition().duration(300).call(
  1323. zoom.transform,
  1324. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  1325. )
  1326. }
  1327. // 定位到指定边(显示边的两个端点)
  1328. function zoomToEdge(sourceId, targetId) {
  1329. const sourceInfo = nodeElements[sourceId]
  1330. const targetInfo = nodeElements[targetId]
  1331. if (!sourceInfo || !targetInfo || !zoom || !containerRef.value) return
  1332. const container = containerRef.value
  1333. const width = container.clientWidth
  1334. const height = container.clientHeight
  1335. // 计算边的中心点和范围
  1336. const centerX = (sourceInfo.x + targetInfo.x) / 2
  1337. const centerY = (sourceInfo.y + targetInfo.y) / 2
  1338. const edgeWidth = Math.abs(sourceInfo.x - targetInfo.x) + 100
  1339. const edgeHeight = Math.abs(sourceInfo.y - targetInfo.y) + 100
  1340. // 计算合适的缩放比例(让边的两端都能显示)
  1341. const scaleX = width / edgeWidth
  1342. const scaleY = height / edgeHeight
  1343. const scale = Math.min(scaleX, scaleY, 1.2) // 最大缩放1.2
  1344. // 计算平移使边居中
  1345. const translateX = width / 2 - centerX * scale
  1346. const translateY = height / 2 - centerY * scale
  1347. const svg = d3.select(svgRef.value)
  1348. svg.transition().duration(300).call(
  1349. zoom.transform,
  1350. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  1351. )
  1352. }
  1353. // 更新高亮/置灰状态
  1354. function updateHighlight() {
  1355. // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
  1356. const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
  1357. applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
  1358. }
  1359. // 点击空白取消(锁定状态下无效果)
  1360. function handleSvgClick(event) {
  1361. // 锁定状态下,点击空白无效果
  1362. if (store.lockedHoverNodeId) return
  1363. const target = event.target
  1364. if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
  1365. store.clearSelection()
  1366. hideLockButton()
  1367. }
  1368. }
  1369. // 监听选中/高亮变化,统一更新
  1370. watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
  1371. updateHighlight()
  1372. if (nodeId && nodeId !== oldNodeId) {
  1373. zoomToNode(nodeId)
  1374. }
  1375. // 重新设置 hover 处理器(起点改变了)
  1376. nextTick(() => setupHoverHandlers())
  1377. })
  1378. watch(() => store.selectedEdgeId, updateHighlight)
  1379. // 监听聚焦边端点变化(由 store 统一管理)
  1380. watch(() => store.focusEdgeEndpoints, (endpoints) => {
  1381. if (endpoints) {
  1382. nextTick(() => {
  1383. zoomToEdge(endpoints.source, endpoints.target)
  1384. })
  1385. }
  1386. })
  1387. watch(() => store.highlightedNodeIds.size, updateHighlight)
  1388. // 监听 hover 状态变化(用于左右联动)
  1389. watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
  1390. if (!svgRef.value) return
  1391. const svg = d3.select(svgRef.value)
  1392. const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
  1393. const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
  1394. const allLabels = svg.selectAll('.match-score, .walked-score')
  1395. if (store.hoverPathNodes.size > 0) {
  1396. // 应用 hover 高亮(支持嵌套:传入锁定路径)
  1397. const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
  1398. applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
  1399. // 如果是从 GraphView 触发的,缩放到显示完整路径
  1400. if (store.hoverSource === 'graph') {
  1401. zoomToPathNodes(store.hoverPathNodes)
  1402. }
  1403. // 在对应节点上显示锁定按钮(无论来源)
  1404. if (store.hoverNodeId) {
  1405. const nodeInfo = nodeElements[store.hoverNodeId]
  1406. if (nodeInfo?.element) {
  1407. showLockButton(nodeInfo.element)
  1408. }
  1409. }
  1410. } else {
  1411. // 清除 hover,恢复原有高亮
  1412. updateHighlight()
  1413. // 如果没有锁定,隐藏按钮
  1414. if (!store.lockedHoverNodeId) {
  1415. hideLockButton()
  1416. }
  1417. }
  1418. })
  1419. // 缩放到显示路径上的所有节点
  1420. function zoomToPathNodes(pathNodes) {
  1421. if (!zoom || !containerRef.value || !svgRef.value) return
  1422. // 收集路径节点的位置
  1423. const positions = []
  1424. for (const nodeId of pathNodes) {
  1425. const nodeInfo = nodeElements[nodeId]
  1426. if (nodeInfo) {
  1427. positions.push({ x: nodeInfo.x, y: nodeInfo.y })
  1428. }
  1429. }
  1430. if (positions.length === 0) return
  1431. // 计算边界框
  1432. const minX = Math.min(...positions.map(p => p.x))
  1433. const maxX = Math.max(...positions.map(p => p.x))
  1434. const minY = Math.min(...positions.map(p => p.y))
  1435. const maxY = Math.max(...positions.map(p => p.y))
  1436. const width = containerRef.value.clientWidth
  1437. const height = containerRef.value.clientHeight
  1438. const padding = 60
  1439. // 计算需要的缩放和平移
  1440. const boxWidth = maxX - minX + padding * 2
  1441. const boxHeight = maxY - minY + padding * 2
  1442. const scale = Math.min(width / boxWidth, height / boxHeight, 1.5)
  1443. const centerX = (minX + maxX) / 2
  1444. const centerY = (minY + maxY) / 2
  1445. const translateX = width / 2 - centerX * scale
  1446. const translateY = height / 2 - centerY * scale
  1447. const svg = d3.select(svgRef.value)
  1448. svg.transition().duration(200).call(
  1449. zoom.transform,
  1450. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  1451. )
  1452. }
  1453. // 监听帖子游走结果变化,渲染游走层
  1454. watch(() => store.postWalkedNodes.length, () => {
  1455. nextTick(renderWalkedLayer)
  1456. })
  1457. // 监听当前帖子变化,重新渲染树
  1458. watch(() => store.currentPostGraph, () => {
  1459. nextTick(() => {
  1460. renderTree()
  1461. })
  1462. }, { immediate: false })
  1463. // 监听 selectedPostIndex 变化,同步下拉框
  1464. watch(() => store.selectedPostIndex, (newIdx) => {
  1465. selectedPostIdx.value = newIdx
  1466. })
  1467. // 恢复锁定的 hover 状态(重新渲染后调用)
  1468. function restoreLockedHover() {
  1469. if (!store.lockedHoverNodeId || !svgRef.value) return
  1470. const svg = d3.select(svgRef.value)
  1471. const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
  1472. const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
  1473. const allLabels = svg.selectAll('.match-score, .walked-score')
  1474. // 恢复高亮效果(传入锁定路径)
  1475. if (store.hoverPathNodes.size > 0) {
  1476. const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
  1477. applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
  1478. }
  1479. // 恢复锁定按钮
  1480. const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
  1481. if (lockedNodeInfo?.element) {
  1482. showLockButton(lockedNodeInfo.element, true)
  1483. }
  1484. }
  1485. // 监听布局变化,过渡结束后重新适应视图
  1486. function handleTransitionEnd(e) {
  1487. if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
  1488. nextTick(() => {
  1489. renderTree()
  1490. nextTick(() => {
  1491. renderWalkedLayer() // 重新渲染游走层
  1492. nextTick(() => {
  1493. updateHighlight() // 重新应用高亮状态
  1494. restoreLockedHover() // 恢复锁定的 hover 状态
  1495. })
  1496. })
  1497. })
  1498. }
  1499. }
  1500. let transitionParent = null
  1501. onMounted(() => {
  1502. nextTick(() => {
  1503. renderTree()
  1504. })
  1505. // 监听父容器的过渡结束事件
  1506. if (containerRef.value) {
  1507. let parent = containerRef.value.parentElement
  1508. while (parent && !parent.classList.contains('transition-all')) {
  1509. parent = parent.parentElement
  1510. }
  1511. if (parent) {
  1512. transitionParent = parent
  1513. parent.addEventListener('transitionend', handleTransitionEnd)
  1514. }
  1515. }
  1516. })
  1517. onUnmounted(() => {
  1518. if (transitionParent) {
  1519. transitionParent.removeEventListener('transitionend', handleTransitionEnd)
  1520. }
  1521. })
  1522. </script>