|
|
@@ -43,14 +43,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, watch } from 'vue'
|
|
|
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
import * as d3 from 'd3'
|
|
|
import { useGraphStore } from '../stores/graph'
|
|
|
|
|
|
@@ -69,6 +69,12 @@ const svgRef = ref(null)
|
|
|
// 当前选中的帖子索引
|
|
|
const selectedPostIdx = ref(store.selectedPostIndex)
|
|
|
|
|
|
+// zoom 实例和主 g 元素
|
|
|
+let zoom = null
|
|
|
+let mainG = null
|
|
|
+let treeWidth = 0
|
|
|
+let treeHeight = 0
|
|
|
+
|
|
|
// 帖子选择变化
|
|
|
function onPostChange() {
|
|
|
store.selectPost(selectedPostIdx.value)
|
|
|
@@ -117,6 +123,14 @@ function renderTree() {
|
|
|
const treeData = store.postTreeData
|
|
|
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
|
|
|
@@ -126,26 +140,55 @@ function renderTree() {
|
|
|
const maxDepth = d3.max(allNodes, d => d.depth)
|
|
|
const leafCount = allNodes.filter(d => !d.children).length
|
|
|
|
|
|
- // 宽度:基于叶子节点数量(垂直布局时叶子横向展开)
|
|
|
- const treeWidth = Math.max(400, leafCount * 80 + 100)
|
|
|
- // 高度:根据深度
|
|
|
- const treeHeight = Math.max(400, (maxDepth + 1) * 100 + 50)
|
|
|
+ // 计算最长文字长度(用于动态调整间距)
|
|
|
+ const maxTextLen = d3.max(allNodes, d => {
|
|
|
+ const name = d.data.name || ''
|
|
|
+ return Math.min(name.length, 10) // 最多显示10个字
|
|
|
+ }) || 6
|
|
|
+
|
|
|
+ // 动态计算节点间距(根据文字长度)
|
|
|
+ const nodeSpacing = Math.max(60, maxTextLen * 12)
|
|
|
+
|
|
|
+ // 宽度:基于叶子节点数量和文字间距
|
|
|
+ treeWidth = Math.max(400, leafCount * nodeSpacing + 100)
|
|
|
+ // 高度:根据深度,增大层间距避免垂直重叠
|
|
|
+ treeHeight = Math.max(400, (maxDepth + 1) * 120 + 50)
|
|
|
+
|
|
|
+ // 创建缩放行为
|
|
|
+ zoom = d3.zoom()
|
|
|
+ .scaleExtent([0.1, 3])
|
|
|
+ .on('zoom', (e) => {
|
|
|
+ mainG.attr('transform', e.transform)
|
|
|
+ })
|
|
|
+
|
|
|
+ svg.call(zoom)
|
|
|
|
|
|
- svg.attr('width', treeWidth).attr('height', treeHeight + 50)
|
|
|
+ // 创建主组
|
|
|
+ mainG = svg.append('g')
|
|
|
|
|
|
// 创建树布局(垂直方向:从上到下)
|
|
|
const treeLayout = d3.tree()
|
|
|
.size([treeWidth - 100, treeHeight - 50])
|
|
|
- .separation((a, b) => a.parent === b.parent ? 1 : 1.5)
|
|
|
+ .separation((a, b) => {
|
|
|
+ // 同级节点间距根据是否有子节点调整
|
|
|
+ if (a.parent === b.parent) {
|
|
|
+ // 叶子节点需要更大间距放文字
|
|
|
+ const aIsLeaf = !a.children
|
|
|
+ const bIsLeaf = !b.children
|
|
|
+ if (aIsLeaf || bIsLeaf) return 1.5
|
|
|
+ return 1
|
|
|
+ }
|
|
|
+ return 2
|
|
|
+ })
|
|
|
|
|
|
treeLayout(root)
|
|
|
|
|
|
- // 创建主组
|
|
|
- const g = svg.append('g')
|
|
|
+ // 内容组(带偏移)
|
|
|
+ const contentG = mainG.append('g')
|
|
|
.attr('transform', 'translate(50, 25)')
|
|
|
|
|
|
// 绘制边(垂直方向)
|
|
|
- g.append('g')
|
|
|
+ contentG.append('g')
|
|
|
.attr('class', 'tree-edges')
|
|
|
.selectAll('.tree-link')
|
|
|
.data(root.links())
|
|
|
@@ -161,7 +204,7 @@ function renderTree() {
|
|
|
})
|
|
|
|
|
|
// 绘制节点(垂直布局:x 是水平位置,y 是垂直位置)
|
|
|
- const nodes = g.append('g')
|
|
|
+ const nodes = contentG.append('g')
|
|
|
.attr('class', 'tree-nodes')
|
|
|
.selectAll('.tree-node')
|
|
|
.data(root.descendants())
|
|
|
@@ -206,7 +249,7 @@ function renderTree() {
|
|
|
.attr('stroke-width', 1)
|
|
|
}
|
|
|
|
|
|
- nodeElements[d.data.id] = this
|
|
|
+ nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
|
|
|
})
|
|
|
|
|
|
// 节点标签(垂直布局:标签在节点下方或右侧)
|
|
|
@@ -234,25 +277,51 @@ function renderTree() {
|
|
|
const maxLen = 10
|
|
|
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)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
// 更新选中/高亮状态
|
|
|
@@ -284,33 +353,19 @@ function clearHighlight() {
|
|
|
|
|
|
// 点击空白取消激活
|
|
|
function handleSvgClick(event) {
|
|
|
- if (event.target.tagName === 'svg') {
|
|
|
+ // 检查是否点击了节点或文字,如果不是则取消激活
|
|
|
+ const target = event.target
|
|
|
+ const isNode = target.closest('.tree-node')
|
|
|
+ if (!isNode) {
|
|
|
clearHighlight()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 滚动到根节点(定位到容器正中间)
|
|
|
-function scrollToRoot() {
|
|
|
- if (!currentRoot) return
|
|
|
- const container = containerRef.value
|
|
|
- if (!container) return
|
|
|
-
|
|
|
- // 垂直布局:x 是水平位置,y 是垂直位置
|
|
|
- const rootX = currentRoot.x + 50 // 水平位置(加上左边距)
|
|
|
- const rootY = currentRoot.y + 25 // 垂直位置(加上上边距)
|
|
|
-
|
|
|
- container.scrollTo({
|
|
|
- left: Math.max(0, rootX - container.clientWidth / 2),
|
|
|
- top: Math.max(0, rootY - container.clientHeight / 2),
|
|
|
- behavior: 'instant'
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
// 监听选中变化
|
|
|
watch(() => store.selectedPostNodeId, (nodeId, oldNodeId) => {
|
|
|
if (nodeId && nodeId !== oldNodeId) {
|
|
|
updateSelection()
|
|
|
- scrollToNode(nodeId)
|
|
|
+ zoomToNode(nodeId)
|
|
|
} else if (!nodeId) {
|
|
|
updateSelection()
|
|
|
}
|
|
|
@@ -323,8 +378,9 @@ watch(() => store.highlightedPostNodeIds.size, () => {
|
|
|
|
|
|
// 监听当前帖子变化,重新渲染树
|
|
|
watch(() => store.currentPostGraph, () => {
|
|
|
- renderTree()
|
|
|
- setTimeout(scrollToRoot, 100)
|
|
|
+ nextTick(() => {
|
|
|
+ renderTree()
|
|
|
+ })
|
|
|
}, { immediate: false })
|
|
|
|
|
|
// 监听 selectedPostIndex 变化,同步下拉框
|
|
|
@@ -332,8 +388,38 @@ watch(() => store.selectedPostIndex, (newIdx) => {
|
|
|
selectedPostIdx.value = newIdx
|
|
|
})
|
|
|
|
|
|
+// 监听布局变化,过渡结束后重新适应视图
|
|
|
+function handleTransitionEnd(e) {
|
|
|
+ if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
|
|
|
+ nextTick(() => {
|
|
|
+ renderTree()
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+let transitionParent = null
|
|
|
+
|
|
|
onMounted(() => {
|
|
|
- renderTree()
|
|
|
- setTimeout(scrollToRoot, 100)
|
|
|
+ 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>
|