TreeView.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. <template>
  2. <div class="flex flex-col h-full">
  3. <!-- 头部 -->
  4. <div v-if="!hideHeader" class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
  5. <span>人设树</span>
  6. <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
  7. 已高亮 {{ store.highlightedNodeIds.size }} 个节点
  8. </span>
  9. </div>
  10. <!-- 搜索框 -->
  11. <div class="px-4 py-2 bg-base-200 border-b border-base-300 relative">
  12. <input
  13. type="text"
  14. v-model="searchQuery"
  15. @input="onSearchInput"
  16. @focus="showSuggestions = true"
  17. @keydown.down.prevent="navigateSuggestion(1)"
  18. @keydown.up.prevent="navigateSuggestion(-1)"
  19. @keydown.enter.prevent="selectSuggestion"
  20. @keydown.escape="showSuggestions = false"
  21. placeholder="搜索节点..."
  22. class="input input-xs input-bordered w-full"
  23. />
  24. <!-- 自动补全下拉 -->
  25. <div
  26. v-show="showSuggestions && filteredNodes.length > 0"
  27. class="absolute left-4 right-4 top-full mt-1 bg-base-100 border border-base-300 rounded shadow-lg max-h-48 overflow-y-auto z-50"
  28. >
  29. <div
  30. v-for="(node, index) in filteredNodes"
  31. :key="node.id"
  32. @click="selectNode(node)"
  33. @mouseenter="suggestionIndex = index"
  34. :class="[
  35. 'px-3 py-1.5 cursor-pointer text-xs flex items-center gap-2',
  36. index === suggestionIndex ? 'bg-primary/20' : 'hover:bg-base-200'
  37. ]"
  38. >
  39. <!-- 标签用圆形,分类用方形 -->
  40. <span
  41. v-if="node.type === '分类'"
  42. class="w-2 h-2 rounded-sm"
  43. :style="{ backgroundColor: getNodeColorById(node) }"
  44. ></span>
  45. <span
  46. v-else
  47. class="w-2 h-2 rounded-full"
  48. :style="{ backgroundColor: getNodeColorById(node) }"
  49. ></span>
  50. <span>{{ node.name }}</span>
  51. <span class="text-base-content/40 text-xs">{{ node.dimension || node.type }}</span>
  52. </div>
  53. </div>
  54. </div>
  55. <!-- SVG 容器 -->
  56. <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
  57. <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
  58. </div>
  59. </div>
  60. </template>
  61. <script setup>
  62. import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
  63. import * as d3 from 'd3'
  64. import { useGraphStore } from '../stores/graph'
  65. import { dimColors, getNodeStyle, applyNodeShape } from '../config/nodeStyle'
  66. import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
  67. const props = defineProps({
  68. hideHeader: {
  69. type: Boolean,
  70. default: false
  71. }
  72. })
  73. const store = useGraphStore()
  74. const containerRef = ref(null)
  75. const svgRef = ref(null)
  76. // zoom 实例和主 g 元素
  77. let zoom = null
  78. let mainG = null
  79. let treeWidth = 0
  80. let treeHeight = 0
  81. // 搜索相关
  82. const searchQuery = ref('')
  83. const showSuggestions = ref(false)
  84. const suggestionIndex = ref(0)
  85. // 获取所有节点列表
  86. const allNodes = computed(() => {
  87. const nodes = store.graphData.nodes || {}
  88. return Object.entries(nodes).map(([id, node]) => ({
  89. id,
  90. ...node
  91. }))
  92. })
  93. // 过滤后的节点(搜索结果)
  94. const filteredNodes = computed(() => {
  95. if (!searchQuery.value.trim()) return []
  96. const query = searchQuery.value.toLowerCase()
  97. return allNodes.value
  98. .filter(node => node.name && node.name.toLowerCase().includes(query))
  99. .slice(0, 20) // 最多显示20个结果
  100. })
  101. // 搜索输入
  102. function onSearchInput() {
  103. showSuggestions.value = true
  104. suggestionIndex.value = 0
  105. }
  106. // 键盘导航
  107. function navigateSuggestion(delta) {
  108. const len = filteredNodes.value.length
  109. if (len === 0) return
  110. suggestionIndex.value = (suggestionIndex.value + delta + len) % len
  111. }
  112. // 选中建议项
  113. function selectSuggestion() {
  114. if (filteredNodes.value.length > 0) {
  115. selectNode(filteredNodes.value[suggestionIndex.value])
  116. }
  117. }
  118. // 选中节点(激活)
  119. function selectNode(node) {
  120. store.selectNode(node)
  121. searchQuery.value = ''
  122. showSuggestions.value = false
  123. }
  124. // 根据节点数据获取颜色(用于搜索列表)
  125. function getNodeColorById(node) {
  126. return getNodeStyle(node).color
  127. }
  128. // 节点元素映射
  129. let nodeElements = {}
  130. let currentRoot = null
  131. // 处理节点点击
  132. function handleNodeClick(event, d) {
  133. event.stopPropagation()
  134. store.selectNode(d)
  135. }
  136. // 渲染树(缩放模式)
  137. function renderTree() {
  138. const svg = d3.select(svgRef.value)
  139. svg.selectAll('*').remove()
  140. nodeElements = {}
  141. const treeData = store.treeData
  142. if (!treeData || !treeData.id) return
  143. const container = containerRef.value
  144. if (!container) return
  145. const width = container.clientWidth
  146. const height = container.clientHeight
  147. svg.attr('viewBox', `0 0 ${width} ${height}`)
  148. // 创建层级数据
  149. const root = d3.hierarchy(treeData)
  150. currentRoot = root
  151. // 智能计算树的尺寸
  152. const allNodes = root.descendants()
  153. const maxDepth = d3.max(allNodes, d => d.depth)
  154. const leafCount = allNodes.filter(d => !d.children).length
  155. // 高度:基于叶子节点数量
  156. treeHeight = Math.max(800, leafCount * 20 + 100)
  157. // 宽度:根据深度
  158. treeWidth = Math.max(600, (maxDepth + 1) * 150 + 50)
  159. // 创建缩放行为
  160. zoom = d3.zoom()
  161. .scaleExtent([0.1, 3])
  162. .on('zoom', (e) => {
  163. mainG.attr('transform', e.transform)
  164. })
  165. svg.call(zoom)
  166. // 创建主组
  167. mainG = svg.append('g')
  168. // 创建树布局
  169. const treeLayout = d3.tree()
  170. .size([treeHeight - 50, treeWidth - 100])
  171. .separation((a, b) => a.parent === b.parent ? 1 : 1.2)
  172. treeLayout(root)
  173. // 内容组(带偏移)
  174. const contentG = mainG.append('g')
  175. .attr('transform', 'translate(25, 25)')
  176. // 绘制边
  177. contentG.append('g')
  178. .attr('class', 'tree-edges')
  179. .selectAll('.tree-link')
  180. .data(root.links())
  181. .join('path')
  182. .attr('class', 'tree-link')
  183. .attr('fill', 'none')
  184. .attr('stroke', '#9b59b6')
  185. .attr('stroke-opacity', 0.3)
  186. .attr('stroke-width', 1)
  187. .attr('d', d => {
  188. const midX = (d.source.y + d.target.y) / 2
  189. return `M${d.source.y},${d.source.x} C${midX},${d.source.x} ${midX},${d.target.x} ${d.target.y},${d.target.x}`
  190. })
  191. // 绘制节点
  192. const nodes = contentG.append('g')
  193. .attr('class', 'tree-nodes')
  194. .selectAll('.tree-node')
  195. .data(root.descendants())
  196. .join('g')
  197. .attr('class', 'tree-node')
  198. .attr('transform', d => `translate(${d.y},${d.x})`)
  199. .style('cursor', 'pointer')
  200. .on('click', handleNodeClick)
  201. // 节点形状(使用统一配置)
  202. nodes.each(function(d) {
  203. const el = d3.select(this)
  204. const style = getNodeStyle(d)
  205. applyNodeShape(el, style).attr('class', 'tree-shape')
  206. nodeElements[d.data.id] = { element: this, x: d.y + 25, y: d.x + 25 }
  207. })
  208. // 节点标签(使用统一配置)
  209. nodes.append('text')
  210. .attr('dy', '0.31em')
  211. .attr('x', d => d.children ? -8 : 8)
  212. .attr('text-anchor', d => d.children ? 'end' : 'start')
  213. .attr('fill', d => getNodeStyle(d).text.fill)
  214. .attr('font-size', d => getNodeStyle(d).text.fontSize)
  215. .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
  216. .text(d => {
  217. const name = d.data.name
  218. const maxLen = d.children ? 6 : 8
  219. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  220. })
  221. // 初始适应视图
  222. fitToView()
  223. }
  224. // 适应视图(自动缩放以显示全部内容)
  225. function fitToView() {
  226. if (!zoom || !mainG || !containerRef.value) return
  227. const container = containerRef.value
  228. const width = container.clientWidth
  229. const height = container.clientHeight
  230. // 计算缩放比例以适应容器
  231. const scaleX = width / treeWidth
  232. const scaleY = height / treeHeight
  233. const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
  234. // 计算居中偏移
  235. const translateX = (width - treeWidth * scale) / 2
  236. const translateY = (height - treeHeight * scale) / 2
  237. const svg = d3.select(svgRef.value)
  238. svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
  239. }
  240. // 定位到指定节点
  241. function zoomToNode(nodeId) {
  242. const nodeInfo = nodeElements[nodeId]
  243. if (!nodeInfo || !zoom || !containerRef.value) return
  244. const container = containerRef.value
  245. const width = container.clientWidth
  246. const height = container.clientHeight
  247. // 计算平移使节点居中
  248. const scale = 1
  249. const translateX = width / 2 - nodeInfo.x * scale
  250. const translateY = height / 2 - nodeInfo.y * scale
  251. const svg = d3.select(svgRef.value)
  252. svg.transition().duration(300).call(
  253. zoom.transform,
  254. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  255. )
  256. }
  257. // 更新选中/高亮状态
  258. function updateSelection() {
  259. applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
  260. }
  261. // 点击空白取消激活
  262. function handleSvgClick(event) {
  263. if (!event.target.closest('.tree-node')) {
  264. store.clearSelection()
  265. }
  266. }
  267. // 监听选中/高亮变化,统一更新
  268. watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
  269. updateSelection()
  270. if (nodeId && nodeId !== oldNodeId) {
  271. zoomToNode(nodeId)
  272. }
  273. })
  274. // 监听聚焦节点变化(由 store 统一管理)
  275. watch(() => store.focusNodeId, (nodeId) => {
  276. if (nodeId) {
  277. zoomToNode(nodeId)
  278. }
  279. })
  280. // 监听选中边变化,更新高亮
  281. watch(() => store.selectedEdgeId, updateSelection)
  282. watch(() => store.highlightedNodeIds.size, updateSelection)
  283. // 监听 hover 状态变化(人设树联动)
  284. watch(() => store.hoverPathNodes.size, () => {
  285. if (!svgRef.value) return
  286. const svg = d3.select(svgRef.value)
  287. const allNodes = svg.selectAll('.tree-node')
  288. const allLinks = svg.selectAll('.tree-link')
  289. if (store.hoverPathNodes.size > 0) {
  290. // 应用路径高亮
  291. applyHoverHighlight(allNodes, allLinks, null, store.hoverPathNodes)
  292. // 如果 hover 的节点在人设树中,居中显示它(跟点击效果一样)
  293. const hoverNodeId = store.hoverNodeId
  294. if (hoverNodeId && nodeElements[hoverNodeId]) {
  295. zoomToNode(hoverNodeId)
  296. }
  297. } else {
  298. // 恢复原有高亮
  299. updateSelection()
  300. }
  301. })
  302. // 监听布局变化,过渡结束后重新适应视图
  303. function handleTransitionEnd(e) {
  304. if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
  305. nextTick(() => {
  306. renderTree()
  307. nextTick(updateSelection) // 重新应用高亮状态
  308. })
  309. }
  310. }
  311. let transitionParent = null
  312. onMounted(() => {
  313. nextTick(() => {
  314. renderTree()
  315. })
  316. // 监听父容器的过渡结束事件
  317. if (containerRef.value) {
  318. let parent = containerRef.value.parentElement
  319. while (parent && !parent.classList.contains('transition-all')) {
  320. parent = parent.parentElement
  321. }
  322. if (parent) {
  323. transitionParent = parent
  324. parent.addEventListener('transitionend', handleTransitionEnd)
  325. }
  326. }
  327. })
  328. onUnmounted(() => {
  329. if (transitionParent) {
  330. transitionParent.removeEventListener('transitionend', handleTransitionEnd)
  331. }
  332. })
  333. </script>