|
|
@@ -55,14 +55,14 @@
|
|
|
</div>
|
|
|
|
|
|
<!-- SVG 容器 -->
|
|
|
- <div ref="containerRef" class="flex-1 overflow-auto bg-base-100">
|
|
|
- <svg ref="svgRef" class="block" @click="handleSvgClick"></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 } from 'vue'
|
|
|
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
import * as d3 from 'd3'
|
|
|
import { useGraphStore } from '../stores/graph'
|
|
|
import { dimColors, getNodeStyle } from '../config/nodeStyle'
|
|
|
@@ -79,6 +79,12 @@ 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)
|
|
|
@@ -129,7 +135,7 @@ function selectNode(node) {
|
|
|
searchQuery.value = ''
|
|
|
showSuggestions.value = false
|
|
|
updateSelection()
|
|
|
- scrollToNode(node.id)
|
|
|
+ zoomToNode(node.id)
|
|
|
}
|
|
|
|
|
|
// 根据节点数据获取颜色(用于搜索列表)
|
|
|
@@ -151,7 +157,7 @@ function handleNodeClick(event, d) {
|
|
|
updateSelection()
|
|
|
}
|
|
|
|
|
|
-// 渲染树(完整显示,不过滤)
|
|
|
+// 渲染树(缩放模式)
|
|
|
function renderTree() {
|
|
|
const svg = d3.select(svgRef.value)
|
|
|
svg.selectAll('*').remove()
|
|
|
@@ -160,6 +166,14 @@ function renderTree() {
|
|
|
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
|
|
|
@@ -170,11 +184,21 @@ function renderTree() {
|
|
|
const leafCount = allNodes.filter(d => !d.children).length
|
|
|
|
|
|
// 高度:基于叶子节点数量
|
|
|
- const treeHeight = Math.max(800, leafCount * 20 + 100)
|
|
|
+ treeHeight = Math.max(800, leafCount * 20 + 100)
|
|
|
// 宽度:根据深度
|
|
|
- const treeWidth = Math.max(600, (maxDepth + 1) * 150 + 50)
|
|
|
+ treeWidth = Math.max(600, (maxDepth + 1) * 150 + 50)
|
|
|
|
|
|
- svg.attr('width', treeWidth).attr('height', treeHeight + 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()
|
|
|
@@ -183,12 +207,12 @@ function renderTree() {
|
|
|
|
|
|
treeLayout(root)
|
|
|
|
|
|
- // 创建主组
|
|
|
- const g = svg.append('g')
|
|
|
+ // 内容组(带偏移)
|
|
|
+ const contentG = mainG.append('g')
|
|
|
.attr('transform', 'translate(25, 25)')
|
|
|
|
|
|
// 绘制边
|
|
|
- g.append('g')
|
|
|
+ contentG.append('g')
|
|
|
.attr('class', 'tree-edges')
|
|
|
.selectAll('.tree-link')
|
|
|
.data(root.links())
|
|
|
@@ -204,7 +228,7 @@ function renderTree() {
|
|
|
})
|
|
|
|
|
|
// 绘制节点
|
|
|
- const nodes = g.append('g')
|
|
|
+ const nodes = contentG.append('g')
|
|
|
.attr('class', 'tree-nodes')
|
|
|
.selectAll('.tree-node')
|
|
|
.data(root.descendants())
|
|
|
@@ -244,7 +268,8 @@ function renderTree() {
|
|
|
.attr('stroke-width', 1)
|
|
|
}
|
|
|
|
|
|
- nodeElements[d.data.id] = this
|
|
|
+ // 记录节点位置(用于 zoomToNode)
|
|
|
+ nodeElements[d.data.id] = { element: this, x: d.y + 25, y: d.x + 25 }
|
|
|
})
|
|
|
|
|
|
// 节点标签(使用统一配置)
|
|
|
@@ -260,25 +285,51 @@ function renderTree() {
|
|
|
const maxLen = d.children ? 6 : 8
|
|
|
return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
|
|
|
})
|
|
|
+
|
|
|
+ // 初始适应视图
|
|
|
+ fitToView()
|
|
|
}
|
|
|
|
|
|
-// 滚动到指定节点
|
|
|
-function scrollToNode(nodeId) {
|
|
|
- const nodeEl = nodeElements[nodeId]
|
|
|
- if (!nodeEl) return
|
|
|
+// 适应视图(自动缩放以显示全部内容)
|
|
|
+function fitToView() {
|
|
|
+ if (!zoom || !mainG || !containerRef.value) return
|
|
|
|
|
|
const container = containerRef.value
|
|
|
- const nodeRect = nodeEl.getBoundingClientRect()
|
|
|
- const containerRect = container.getBoundingClientRect()
|
|
|
+ const width = container.clientWidth
|
|
|
+ const height = container.clientHeight
|
|
|
|
|
|
- const scrollTop = container.scrollTop + nodeRect.top - containerRect.top - containerRect.height / 2
|
|
|
- const scrollLeft = container.scrollLeft + nodeRect.left - containerRect.left - containerRect.width / 2
|
|
|
+ // 计算缩放比例以适应容器
|
|
|
+ const scaleX = width / treeWidth
|
|
|
+ const scaleY = height / treeHeight
|
|
|
+ const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
|
|
|
|
|
|
- container.scrollTo({
|
|
|
- top: Math.max(0, scrollTop),
|
|
|
- left: Math.max(0, scrollLeft),
|
|
|
- behavior: 'smooth'
|
|
|
- })
|
|
|
+ // 计算居中偏移
|
|
|
+ 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)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
// 更新选中/高亮状态
|
|
|
@@ -310,38 +361,19 @@ function clearHighlight() {
|
|
|
|
|
|
// 点击空白取消激活
|
|
|
function handleSvgClick(event) {
|
|
|
- // 只有点击 SVG 背景才清除
|
|
|
- if (event.target.tagName === 'svg') {
|
|
|
+ const target = event.target
|
|
|
+ const isNode = target.closest('.tree-node')
|
|
|
+ if (!isNode) {
|
|
|
clearHighlight()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 滚动条默认滚到中间
|
|
|
-function scrollToCenter() {
|
|
|
- const container = containerRef.value
|
|
|
- if (!container) return
|
|
|
-
|
|
|
- const svg = svgRef.value
|
|
|
- if (!svg) return
|
|
|
-
|
|
|
- // 滚动到内容的中间位置
|
|
|
- const scrollLeft = (svg.clientWidth - container.clientWidth) / 2
|
|
|
- const scrollTop = (svg.clientHeight - container.clientHeight) / 2
|
|
|
-
|
|
|
- container.scrollTo({
|
|
|
- left: Math.max(0, scrollLeft),
|
|
|
- top: Math.max(0, scrollTop),
|
|
|
- behavior: 'instant'
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
// 监听选中变化(从外部触发)
|
|
|
watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
|
|
|
if (nodeId && nodeId !== oldNodeId) {
|
|
|
updateSelection()
|
|
|
- scrollToNode(nodeId)
|
|
|
+ zoomToNode(nodeId)
|
|
|
} else if (!nodeId) {
|
|
|
- // 清除选中时也要更新
|
|
|
updateSelection()
|
|
|
}
|
|
|
})
|
|
|
@@ -351,58 +383,21 @@ watch(() => store.highlightedNodeIds.size, () => {
|
|
|
updateSelection()
|
|
|
})
|
|
|
|
|
|
-// 记录滚动比例,用于布局变化后恢复
|
|
|
-let scrollRatioX = 0.5
|
|
|
-let scrollRatioY = 0.5
|
|
|
-
|
|
|
-// 保存当前滚动比例
|
|
|
-function saveScrollRatio() {
|
|
|
- const container = containerRef.value
|
|
|
- if (!container) return
|
|
|
-
|
|
|
- const maxScrollLeft = container.scrollWidth - container.clientWidth
|
|
|
- const maxScrollTop = container.scrollHeight - container.clientHeight
|
|
|
-
|
|
|
- scrollRatioX = maxScrollLeft > 0 ? container.scrollLeft / maxScrollLeft : 0.5
|
|
|
- scrollRatioY = maxScrollTop > 0 ? container.scrollTop / maxScrollTop : 0.5
|
|
|
-}
|
|
|
-
|
|
|
-// 恢复滚动比例
|
|
|
-function restoreScrollRatio() {
|
|
|
- const container = containerRef.value
|
|
|
- if (!container) return
|
|
|
-
|
|
|
- const maxScrollLeft = container.scrollWidth - container.clientWidth
|
|
|
- const maxScrollTop = container.scrollHeight - container.clientHeight
|
|
|
-
|
|
|
- container.scrollTo({
|
|
|
- left: maxScrollLeft * scrollRatioX,
|
|
|
- top: maxScrollTop * scrollRatioY,
|
|
|
- behavior: 'instant'
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-// 布局变化时保存滚动位置
|
|
|
-watch(() => store.expandedPanel, () => {
|
|
|
- saveScrollRatio()
|
|
|
-})
|
|
|
-
|
|
|
-// 监听过渡结束,恢复滚动位置
|
|
|
+// 监听布局变化,过渡结束后重新适应视图
|
|
|
function handleTransitionEnd(e) {
|
|
|
if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
|
|
|
- if (store.selectedNodeId) {
|
|
|
- scrollToNode(store.selectedNodeId)
|
|
|
- } else {
|
|
|
- restoreScrollRatio()
|
|
|
- }
|
|
|
+ nextTick(() => {
|
|
|
+ renderTree()
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
let transitionParent = null
|
|
|
|
|
|
onMounted(() => {
|
|
|
- renderTree()
|
|
|
- setTimeout(scrollToCenter, 100)
|
|
|
+ nextTick(() => {
|
|
|
+ renderTree()
|
|
|
+ })
|
|
|
|
|
|
// 监听父容器的过渡结束事件
|
|
|
if (containerRef.value) {
|