1. 问题背景
项目中有一个需求:后端返回树状结构,可能是树,也可能是森林,前端需要从众多的树中查找出当前节点所在的树分支,并且以对话框的形式展示当前节点所在的树分支。
2. 思路分析
- 由于我的项目中数据是通过表格来展示的,当点击每一行时要获取当前行所在的id,并通过当前id来判断出所在的树分支;
- 查找到所需要的树分支后,另一个问题就是如何展示树状结构,我找了两种方案:
-
- 使用 vue3-tree-org;
-
- 使用 echarts(5.3.3) 中的树状结构;
-
在使用第一种方案的时候在树中节点比较少的时候很难居中,并且当节点过多或者是过宽的时候比较不好调整节点之间的距离,所以我使用了第二种方案。
- 在确定使用 echarts 图表来渲染树状结构后,遇到最棘手的问题就是如何在对话框定宽定高的情况下根据节点的内容动态来加载图表的宽度和高度。
- 在查阅了众多资料后发现可以在初始化表格的时候给一个初始宽度和初始高度,而这个初始高度和初始宽度要根据此节点所在的树形结构来动态的变化。
3. 项目代码
由于本项目使用的是 vue3 + ts,所以代码中会有一些ts类型声明,读者可自行更改,并且仅贴出重要的代码。
3.1 获取特定的树分支
// in case that the tree maybe is more than one, so we need to choose the correct tree
export const getTopTreeId = (tree: InitType[], choosenId: number) => {
if (!tree.length) return
for (const item of tree) {
const initTopId = item.value
const stack: InitType[] = []
stack.push(item)
while (stack.length > 0) {
const node = stack.pop()
if (node?.value === choosenId) {
return initTopId
} else if (node?.children && node?.children.length) {
for (let i = 0; i < node.children.length; i++) {
stack.push(node.children[i])
}
}
}
}
return null
}
3.2 获取树形图的高度和宽度
// get the tree depth
export const getTreeDepth = (tree: ThemeTreeOrgInit) => {
let maxDepth = 0
function traverse(node: ThemeTreeOrgInit, depth: number) {
if (node.children.length === 0) {
maxDepth = Math.max(maxDepth, depth)
} else {
for (const child of node.children) {
traverse(child, depth + 1)
}
}
}
traverse(tree, 1)
return maxDepth
}
// get the tree width
export const getTreeWidth = (tree: ThemeTreeOrgInit) => {
let maxWidth = 0
const queue = []
queue.push(tree)
while (queue.length > 0) {
const width = queue.length
maxWidth = Math.max(maxWidth, width)
for (let i = 0; i < width; i++) {
const node = queue.shift()
for (let j = 0; j < node!.children.length; j++) {
queue.push(node?.children[j])
}
}
}
return maxWidth
}
3.3 获取最多节点层中的首尾孩子最大节点个数
本项目要求树形节点及树形层级间的距离尽可能等长,所以如果仅以最大层级的节点个数作为echarts图表的最大宽度,当最大层级的首尾孩子节点左右跨度比较大的时候,这种情况节点之间的距离会减小,因此我们需要求出最大层级首尾孩子节点的最大个数
// reutn a two dimensions array of the every level node summary
// such as: [[1],[2,3,4,5],[6,7,8,9,10],[11,12,13],[14]]
export const getLevelOrder = (root: ThemeTreeOrgInit) => {
const res: ThemeTreeOrgInit[][] = []
if (!root) {
return res
}
let frontier = [root]
while (frontier.length) {
const next: ThemeTreeOrgInit[] = []
res.push(frontier.map((node) => node))
frontier.forEach((node) => {
if (node.children) {
next.push(...node.children)
}
})
frontier = next
}
return res
}
export const getMaxLevelChildrenLength = (array: ThemeTreeOrgInit[][]) => {
const lengths = array.map((subArray) => subArray.length)
const maxLength = Math.max(...lengths)
const tempArray = array.filter((subArray) => subArray.length === maxLength).flat()
// getTreeWidth default return the top tree depth
return {
leftChild: getTreeWidth(tempArray[0]) === 1 ? 0 : getTreeWidth(tempArray[0]),
rightChild:
getTreeWidth(tempArray[tempArray.length - 1]) === 1
? 0
: getTreeWidth(tempArray[tempArray.length - 1])
}
}
3.4 echarts 树状图的展示
watch(
() => props.modelValue,
async (newValue) => {
if (newValue === true) {
// get the tree data from the back-end
} else {
window.removeEventListener('resize', chartResize)
}
}
)
const treeDepth = ref(0)
const treeWidth = ref(0)
const echartsRef = ref<HTMLElement>()
const myTreeChart = ref<echarts.EChartsType>()
const getOptions = () => ({
grid: {
top: 20,
right: 16,
bottom: 20,
left: 16
},
series: [
{
type: 'tree',
id: 0,
name: 'tree1',
data: [your own data],
expandAndCollapse: false,
orient: 'vertical',
top: '10%',
left: '2%',
bottom: '10%',
right: '2%',
symbolSize: 7, //标记的大小, 长方形的宽、高
symbol: 'circle', // 长方形,可以通过 'image://url' 设置为图片
edgeShape: 'polyline', // 分别有曲线和折线两种,对应的取值是 curve 和 polyline
edgeForkPosition: '50%', //子树中折线段分叉的位置
initialTreeDepth: -1,
itemStyle: {
//树图中每个节点的样式
color: '#C9C9C9', //节点背景色
borderWidth: 0
},
lineStyle: {
color: '#C9C9C9',
width: 1,
type: 'solid' // 连线的样式 'curve'|'broken'|'solid'|'dotted'|'dashed'
},
label: {
//每个节点所对应的标签的样式
backgroundColor: '#4c8bff',
borderRadius: 20,
color: '#fff',
position: 'top', //标签的位置
verticalAlign: 'middle', //文字垂直对齐方式,默认自动。可选:top,middle,bottom
align: 'center', //文字水平对齐方式,默认自动。可选:top,center,bottom
// lineHeight: 40,
fontSize: 14,
width: 110,
height: 40,
overflow: 'breakAll' //'truncate' 截断,并在末尾显示ellipsis配置的文本,默认为...
},
leaves: {
label: {
position: 'bottom'
// verticalAlign: 'top',
// align: 'center'
}
},
animationDuration: 10, //初始动画的时长,支持回调函数,默认1000
animationDurationUpdate: 10 //数据更新动画的时长,默认300
}
]
})
const drawChart = async () => {
await nextTick(() => {
if (echartsRef.value) {
treeDepth.value = getTreeDepth(tableInfo.data)
treeWidth.value = getTreeWidth(tableInfo.data)
const tempArr = getLevelOrder(tableInfo.data)
const { leftChild, rightChild } = getMaxLevelChildrenLength(tempArr)
treeWidth.value = treeWidth.value + Math.floor(leftChild / 2) + Math.floor(rightChild / 2)
myTreeChart.value = echarts.init(echartsRef.value as HTMLElement, '', {
// 180 = label width(110) + node padding(70)
width: treeWidth.value * 180,
height: treeDepth.value * 180
})
const option = getOptions()
myTreeChart.value.setOption(option)
window.addEventListener('resize', chartResize)
}
})
}
const chartResize = () => {
myTreeChart.value?.resize()
}
3.5 节点数量少时树形结构需要居中
- 使用** display:flex **布局
- justify-content:center
<template>
<el-dialog
:model-value="modelValue"
center
width="60%"
:close-on-click-modal="false"
:destroy-on-close="true"
@closed="closeDetailInfo"
>
<div ref="echartsRef" id="main" class="main-test"></div>
</el-dialog>
</template>
<style lang="scss" scoped>
.main-test {
display: flex;
justify-content: center;
width: 100%;
height: 600px;
overflow: auto;
margin: 0 auto;
}
</style>