TreeView.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  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 } from '../config/nodeStyle'
  66. import { applyHighlight } 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. if (style.shape === 'rect') {
  206. el.append('rect')
  207. .attr('class', 'tree-shape')
  208. .attr('x', -4)
  209. .attr('y', -4)
  210. .attr('width', 8)
  211. .attr('height', 8)
  212. .attr('rx', 1)
  213. .attr('fill', style.color)
  214. .attr('stroke', 'rgba(255,255,255,0.5)')
  215. .attr('stroke-width', 1)
  216. } else {
  217. el.append('circle')
  218. .attr('class', 'tree-shape')
  219. .attr('r', style.size / 2)
  220. .attr('fill', style.color)
  221. .attr('stroke', 'rgba(255,255,255,0.5)')
  222. .attr('stroke-width', 1)
  223. }
  224. // 记录节点位置(用于 zoomToNode)
  225. nodeElements[d.data.id] = { element: this, x: d.y + 25, y: d.x + 25 }
  226. })
  227. // 节点标签(使用统一配置)
  228. nodes.append('text')
  229. .attr('dy', '0.31em')
  230. .attr('x', d => d.children ? -8 : 8)
  231. .attr('text-anchor', d => d.children ? 'end' : 'start')
  232. .attr('fill', d => getNodeStyle(d).text.fill)
  233. .attr('font-size', d => getNodeStyle(d).text.fontSize)
  234. .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
  235. .text(d => {
  236. const name = d.data.name
  237. const maxLen = d.children ? 6 : 8
  238. return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
  239. })
  240. // 初始适应视图
  241. fitToView()
  242. }
  243. // 适应视图(自动缩放以显示全部内容)
  244. function fitToView() {
  245. if (!zoom || !mainG || !containerRef.value) return
  246. const container = containerRef.value
  247. const width = container.clientWidth
  248. const height = container.clientHeight
  249. // 计算缩放比例以适应容器
  250. const scaleX = width / treeWidth
  251. const scaleY = height / treeHeight
  252. const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
  253. // 计算居中偏移
  254. const translateX = (width - treeWidth * scale) / 2
  255. const translateY = (height - treeHeight * scale) / 2
  256. const svg = d3.select(svgRef.value)
  257. svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
  258. }
  259. // 定位到指定节点
  260. function zoomToNode(nodeId) {
  261. const nodeInfo = nodeElements[nodeId]
  262. if (!nodeInfo || !zoom || !containerRef.value) return
  263. const container = containerRef.value
  264. const width = container.clientWidth
  265. const height = container.clientHeight
  266. // 计算平移使节点居中
  267. const scale = 1
  268. const translateX = width / 2 - nodeInfo.x * scale
  269. const translateY = height / 2 - nodeInfo.y * scale
  270. const svg = d3.select(svgRef.value)
  271. svg.transition().duration(300).call(
  272. zoom.transform,
  273. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  274. )
  275. }
  276. // 更新选中/高亮状态
  277. function updateSelection() {
  278. applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
  279. }
  280. // 点击空白取消激活
  281. function handleSvgClick(event) {
  282. if (!event.target.closest('.tree-node')) {
  283. store.clearSelection()
  284. }
  285. }
  286. // 监听选中/高亮变化,统一更新
  287. watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
  288. updateSelection()
  289. if (nodeId && nodeId !== oldNodeId) {
  290. zoomToNode(nodeId)
  291. }
  292. })
  293. watch(() => store.highlightedNodeIds.size, updateSelection)
  294. // 监听布局变化,过渡结束后重新适应视图
  295. function handleTransitionEnd(e) {
  296. if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
  297. nextTick(() => {
  298. renderTree()
  299. })
  300. }
  301. }
  302. let transitionParent = null
  303. onMounted(() => {
  304. nextTick(() => {
  305. renderTree()
  306. })
  307. // 监听父容器的过渡结束事件
  308. if (containerRef.value) {
  309. let parent = containerRef.value.parentElement
  310. while (parent && !parent.classList.contains('transition-all')) {
  311. parent = parent.parentElement
  312. }
  313. if (parent) {
  314. transitionParent = parent
  315. parent.addEventListener('transitionend', handleTransitionEnd)
  316. }
  317. }
  318. })
  319. onUnmounted(() => {
  320. if (transitionParent) {
  321. transitionParent.removeEventListener('transitionend', handleTransitionEnd)
  322. }
  323. })
  324. </script>