| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394 |
- <template>
- <div class="flex flex-col h-full">
- <!-- 头部 -->
- <div v-if="!hideHeader" class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
- <span>人设树</span>
- <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
- 已高亮 {{ store.highlightedNodeIds.size }} 个节点
- </span>
- </div>
- <!-- 搜索框 -->
- <div class="px-4 py-2 bg-base-200 border-b border-base-300 relative">
- <input
- type="text"
- v-model="searchQuery"
- @input="onSearchInput"
- @focus="showSuggestions = true"
- @keydown.down.prevent="navigateSuggestion(1)"
- @keydown.up.prevent="navigateSuggestion(-1)"
- @keydown.enter.prevent="selectSuggestion"
- @keydown.escape="showSuggestions = false"
- placeholder="搜索节点..."
- class="input input-xs input-bordered w-full"
- />
- <!-- 自动补全下拉 -->
- <div
- v-show="showSuggestions && filteredNodes.length > 0"
- 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"
- >
- <div
- v-for="(node, index) in filteredNodes"
- :key="node.id"
- @click="selectNode(node)"
- @mouseenter="suggestionIndex = index"
- :class="[
- 'px-3 py-1.5 cursor-pointer text-xs flex items-center gap-2',
- index === suggestionIndex ? 'bg-primary/20' : 'hover:bg-base-200'
- ]"
- >
- <!-- 标签用圆形,分类用方形 -->
- <span
- v-if="node.type === '分类'"
- class="w-2 h-2 rounded-sm"
- :style="{ backgroundColor: getNodeColorById(node) }"
- ></span>
- <span
- v-else
- class="w-2 h-2 rounded-full"
- :style="{ backgroundColor: getNodeColorById(node) }"
- ></span>
- <span>{{ node.name }}</span>
- <span class="text-base-content/40 text-xs">{{ node.dimension || node.type }}</span>
- </div>
- </div>
- </div>
- <!-- SVG 容器 -->
- <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
- <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
- import * as d3 from 'd3'
- import { useGraphStore } from '../stores/graph'
- import { dimColors, getNodeStyle, applyNodeShape } from '../config/nodeStyle'
- import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
- const props = defineProps({
- hideHeader: {
- type: Boolean,
- default: false
- }
- })
- const store = useGraphStore()
- const containerRef = ref(null)
- const svgRef = ref(null)
- // zoom 实例和主 g 元素
- let zoom = null
- let mainG = null
- let treeWidth = 0
- let treeHeight = 0
- // 搜索相关
- const searchQuery = ref('')
- const showSuggestions = ref(false)
- const suggestionIndex = ref(0)
- // 获取所有节点列表
- const allNodes = computed(() => {
- const nodes = store.graphData.nodes || {}
- return Object.entries(nodes).map(([id, node]) => ({
- id,
- ...node
- }))
- })
- // 过滤后的节点(搜索结果)
- const filteredNodes = computed(() => {
- if (!searchQuery.value.trim()) return []
- const query = searchQuery.value.toLowerCase()
- return allNodes.value
- .filter(node => node.name && node.name.toLowerCase().includes(query))
- .slice(0, 20) // 最多显示20个结果
- })
- // 搜索输入
- function onSearchInput() {
- showSuggestions.value = true
- suggestionIndex.value = 0
- }
- // 键盘导航
- function navigateSuggestion(delta) {
- const len = filteredNodes.value.length
- if (len === 0) return
- suggestionIndex.value = (suggestionIndex.value + delta + len) % len
- }
- // 选中建议项
- function selectSuggestion() {
- if (filteredNodes.value.length > 0) {
- selectNode(filteredNodes.value[suggestionIndex.value])
- }
- }
- // 选中节点(激活)
- function selectNode(node) {
- store.selectNode(node)
- searchQuery.value = ''
- showSuggestions.value = false
- }
- // 根据节点数据获取颜色(用于搜索列表)
- function getNodeColorById(node) {
- return getNodeStyle(node).color
- }
- // 节点元素映射
- let nodeElements = {}
- let currentRoot = null
- // 处理节点点击
- function handleNodeClick(event, d) {
- event.stopPropagation()
- store.selectNode(d)
- }
- // 渲染树(缩放模式)
- function renderTree() {
- const svg = d3.select(svgRef.value)
- svg.selectAll('*').remove()
- nodeElements = {}
- const treeData = store.treeData
- if (!treeData || !treeData.id) return
- const container = containerRef.value
- if (!container) return
- const width = container.clientWidth
- const height = container.clientHeight
- svg.attr('viewBox', `0 0 ${width} ${height}`)
- // 创建层级数据
- const root = d3.hierarchy(treeData)
- currentRoot = root
- // 智能计算树的尺寸
- const allNodes = root.descendants()
- const maxDepth = d3.max(allNodes, d => d.depth)
- const leafCount = allNodes.filter(d => !d.children).length
- // 高度:基于叶子节点数量
- treeHeight = Math.max(800, leafCount * 20 + 100)
- // 宽度:根据深度
- treeWidth = Math.max(600, (maxDepth + 1) * 150 + 50)
- // 创建缩放行为
- zoom = d3.zoom()
- .scaleExtent([0.1, 3])
- .on('zoom', (e) => {
- mainG.attr('transform', e.transform)
- })
- svg.call(zoom)
- // 创建主组
- mainG = svg.append('g')
- // 创建树布局
- const treeLayout = d3.tree()
- .size([treeHeight - 50, treeWidth - 100])
- .separation((a, b) => a.parent === b.parent ? 1 : 1.2)
- treeLayout(root)
- // 内容组(带偏移)
- const contentG = mainG.append('g')
- .attr('transform', 'translate(25, 25)')
- // 绘制边
- contentG.append('g')
- .attr('class', 'tree-edges')
- .selectAll('.tree-link')
- .data(root.links())
- .join('path')
- .attr('class', 'tree-link')
- .attr('fill', 'none')
- .attr('stroke', '#9b59b6')
- .attr('stroke-opacity', 0.3)
- .attr('stroke-width', 1)
- .attr('d', d => {
- const midX = (d.source.y + d.target.y) / 2
- return `M${d.source.y},${d.source.x} C${midX},${d.source.x} ${midX},${d.target.x} ${d.target.y},${d.target.x}`
- })
- // 绘制节点
- const nodes = contentG.append('g')
- .attr('class', 'tree-nodes')
- .selectAll('.tree-node')
- .data(root.descendants())
- .join('g')
- .attr('class', 'tree-node')
- .attr('transform', d => `translate(${d.y},${d.x})`)
- .style('cursor', 'pointer')
- .on('click', handleNodeClick)
- // 节点形状(使用统一配置)
- nodes.each(function(d) {
- const el = d3.select(this)
- const style = getNodeStyle(d)
- applyNodeShape(el, style).attr('class', 'tree-shape')
- nodeElements[d.data.id] = { element: this, x: d.y + 25, y: d.x + 25 }
- })
- // 节点标签(使用统一配置)
- nodes.append('text')
- .attr('dy', '0.31em')
- .attr('x', d => d.children ? -8 : 8)
- .attr('text-anchor', d => d.children ? 'end' : 'start')
- .attr('fill', d => getNodeStyle(d).text.fill)
- .attr('font-size', d => getNodeStyle(d).text.fontSize)
- .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
- .text(d => {
- const name = d.data.name
- const maxLen = d.children ? 6 : 8
- return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
- })
- // 初始适应视图
- fitToView()
- }
- // 适应视图(自动缩放以显示全部内容)
- function fitToView() {
- if (!zoom || !mainG || !containerRef.value) return
- const container = containerRef.value
- const width = container.clientWidth
- const height = container.clientHeight
- // 计算缩放比例以适应容器
- const scaleX = width / treeWidth
- const scaleY = height / treeHeight
- const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
- // 计算居中偏移
- const translateX = (width - treeWidth * scale) / 2
- const translateY = (height - treeHeight * scale) / 2
- const svg = d3.select(svgRef.value)
- svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
- }
- // 定位到指定节点
- function zoomToNode(nodeId) {
- const nodeInfo = nodeElements[nodeId]
- if (!nodeInfo || !zoom || !containerRef.value) return
- const container = containerRef.value
- const width = container.clientWidth
- const height = container.clientHeight
- // 计算平移使节点居中
- const scale = 1
- const translateX = width / 2 - nodeInfo.x * scale
- const translateY = height / 2 - nodeInfo.y * scale
- const svg = d3.select(svgRef.value)
- svg.transition().duration(300).call(
- zoom.transform,
- d3.zoomIdentity.translate(translateX, translateY).scale(scale)
- )
- }
- // 更新选中/高亮状态
- function updateSelection() {
- applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
- }
- // 点击空白取消激活
- function handleSvgClick(event) {
- if (!event.target.closest('.tree-node')) {
- store.clearSelection()
- }
- }
- // 监听选中/高亮变化,统一更新
- watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
- updateSelection()
- if (nodeId && nodeId !== oldNodeId) {
- zoomToNode(nodeId)
- }
- })
- // 监听聚焦节点变化(由 store 统一管理)
- watch(() => store.focusNodeId, (nodeId) => {
- if (nodeId) {
- zoomToNode(nodeId)
- }
- })
- // 监听选中边变化,更新高亮
- watch(() => store.selectedEdgeId, updateSelection)
- watch(() => store.highlightedNodeIds.size, updateSelection)
- // 监听 hover 状态变化(人设树联动)
- watch(() => store.hoverPathNodes.size, () => {
- if (!svgRef.value) return
- const svg = d3.select(svgRef.value)
- const allNodes = svg.selectAll('.tree-node')
- const allLinks = svg.selectAll('.tree-link')
- if (store.hoverPathNodes.size > 0) {
- // 应用路径高亮
- applyHoverHighlight(allNodes, allLinks, null, store.hoverPathNodes)
- // 如果 hover 的节点在人设树中,居中显示它(跟点击效果一样)
- const hoverNodeId = store.hoverNodeId
- if (hoverNodeId && nodeElements[hoverNodeId]) {
- zoomToNode(hoverNodeId)
- }
- } else {
- // 恢复原有高亮
- updateSelection()
- }
- })
- // 监听布局变化,过渡结束后重新适应视图
- function handleTransitionEnd(e) {
- if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
- nextTick(() => {
- renderTree()
- nextTick(updateSelection) // 重新应用高亮状态
- })
- }
- }
- let transitionParent = null
- onMounted(() => {
- nextTick(() => {
- renderTree()
- })
- // 监听父容器的过渡结束事件
- if (containerRef.value) {
- let parent = containerRef.value.parentElement
- while (parent && !parent.classList.contains('transition-all')) {
- parent = parent.parentElement
- }
- if (parent) {
- transitionParent = parent
- parent.addEventListener('transitionend', handleTransitionEnd)
- }
- }
- })
- onUnmounted(() => {
- if (transitionParent) {
- transitionParent.removeEventListener('transitionend', handleTransitionEnd)
- }
- })
- </script>
|