[canvas]原来画树状图没那么难,下次别找库了
先看成果
场景
组织结构图、文件图等。
canvas 基础
多的不介绍,只介绍这次用到的一些API,详细的API就点这里看看。
moveTo
移动canvas画笔的点到指定坐标,可以理解为笔从纸上拿起来,然后移动到指定位置。
const canvas = document.getElementById('canvas')
const cxt = canvas.getContext('2d');
cxt.moveTo(100, 100);
lineTo
移动canvas画笔的点到指定坐标,可以理解为笔接触到纸,然后移动到指定位置。
const canvas = document.getElementById('canvas')
const cxt = canvas.getContext('2d');
cxt.lineTo(200, 100);
stroke
将所有坐标用一条线连起来(beginPath
新开路径之后的点)
<canvas
id="canvas"
width="300"
height="500"
>你的浏览器不支持canvas</canvas>
<script>
const canvas = document.getElementById('canvas')
// 1、获取 canvas 上下文环境对象
const cxt = canvas.getContext('2d')
// 2、绘制图形
cxt.moveTo(100, 100)
cxt.lineTo(200, 100)
cxt.stroke() // 将起点和终点连接起来
</script>
rect
画个矩形
const cxt = cnv.getContext('2d');
ctx.rect(startX, startY, width, height);
类型
树节点类型
// 非常的简单明了,什么多余的东西都没有
export interface TreeNode {
// 显示标题
title: string;
// 下一级
children: TreeNode[];
}
绘制
先建个新的vue页面吧
<script setup name="TreeView" lang="ts">
import { reactive, watch, ref, onMounted, shallowRef } from "vue";
// 保存 canvas 的ref
const canvasRef = shallowRef<HTMLCanvasElement>();
// 保存 canvasContext 的ref
const canvasContext = shallowRef<CanvasRenderingContext2D | null>();
// 方便后面方法中取context,不然就得一直用 `canvasContext.value!`
function getCtx(): CanvasRenderingContext2D {
return canvasContext.value!;
}
onMounted(() => {
// 组件挂载时初始化 context
canvasContext.value = canvasRef.value?.getContext("2d");
});
</script>
<template>
<canvas ref="canvasRef" id="canvas" width="500" height="500">
你的浏览器不支持canvas。
</canvas>
</template>
绘制一个普通节点
function drawTextItem({
startX,
startY,
width,
height,
title,
}: {
startX: number;
startY: number;
width: number;
height: number;
title: string;
}) {
const ctx = getCtx();
// 重新开辟一个线路
ctx.beginPath();
// 画一个外边框
ctx.rect(startX, startY, width, height);
ctx.strokeStyle = "red";
ctx.fillStyle = "pink";
ctx.stroke();
ctx.fill();
// 写字
ctx.fillStyle = "red";
ctx.textBaseline = "middle";
// fillText(text, x, y, maxWidth),maxWidth最大宽度
ctx.fillText(title, startX, startY, width);
}
调用一下
drawTextItem({
startX: 10,
startY: 10,
width: 100,
height: 50,
title: '一个普通节点',
});
🎉🎉🎉看起来非常的简单,我们似乎已经实现了一半的需求!(另一半就是画画线把节点连接起来了😉),接下来简单设计一下树结构。
树结构
// type.ts
export interface TreeNode {
// 显示标题
title: string;
// 下一级
children?: TreeNode[];
}
然后随便准备点数据
const tree: TreeNode = {
title: "一个普通节点",
children: [
{
title: "普通的男孩",
},
{
title: "普通的女孩",
},
],
};
有了一个很简单的树,然后就可以开始绘制根节点下面的子节点了。
绘制子节点
简单讲一下思路,上面这个树的根节点有两个子节点,所以我们只要随便在一个地方画上根节点,然后在根节点的下方把子节点排排站画上就好了。
/**
*
* @param startX 当前节点起始坐标X
* @param startY 当前节点起始坐标Y
* @param node 节点
*/
function render(startX: number, startY: number, node: TreeNode) {
// 绘制一个节点需要的高度
const nodeHeight = 50;
// 绘制一个节点需要的宽度
const nodeWidth = 100;
// 节点之间留出来点垂直方向的间隙
const nodeMarginY = 10;
// 节点之间留出来点水平方向的间隙
const nodeMarginX = 10;
// 绘制节点
drawTextItem({
startX,
startY,
width: nodeWidth,
height: nodeHeight,
title: node.title,
});
// 如果有子节点,绘制子节点
if (node.children && node.children.length > 0) {
// 计算当前节点的子节点的 Y 坐标的起始位置
// 其实就是父节点的 startY 加上一个节点高度和一个margin
const childrenStartY = startY + nodeHeight + nodeMarginY;
// 子节点的 startX
// 绘制完一个之后,会重新设置这个值
let lastStartX = startX;
node.children.forEach((e) => {
// 遍历渲染子节点
render(lastStartX, childrenStartY, e);
// 重新设置一下 lastStartX,给下一个子节点用,这里也是加上一个节点宽度和margin
lastStartX = lastStartX + nodeWidth + nodeMarginX;
});
}
}
看看效果
🎉看起来还不错,有点内味了,不过就是不居中。修改一下
lastStartX
,让子节点居中渲染。
// 子节点的 startX
// 绘制完一个之后,会重新设置这个值
// 初始值,将居中点对齐
// 父节点的中心点
const parentCenterX = startX + nodeWidth / 2;
// 子节点一半的宽度
const childListHalfWidth = (node.children.length * (nodeWidth + nodeMarginX) - nodeMarginX) / 2;
// 父节点中心点减去子节点一半宽度,相当于把 子节点往左平移一半距离,达到居中目的。
let lastStartX = parentCenterX - childListHalfWidth;
往左移直接移出画布了😅,没关系,把根节点
startX
设大一下就好了。
render(150, 10, newVal);
看起来已经完成了任务,让我们试试绘制一下省市区结构吧。
一个省两个市四个区👇
const tree: TreeNode = {
title: "浙江省",
children: [
{
title: "杭州市",
children: [
{
title: "拱墅区",
},
{
title: "余杭区",
},
],
},
{
title: "宁波市",
children: [
{
title: "海曙区",
},
{
title: "江北区",
},
],
},
],
};
问题出现了,杭州市下面的余杭区被宁波的海曙区盖住了,看来上面渲染函数的逻辑出现了问题。
思考
如何合理的在一张纸上绘制一个树,这个问题困扰了我差不多一周时间。主要是现实生活里我不知道怎么均匀的、不冲突的把树的所有节点绘制在一张上,我拿着白板笔在玻璃上擦擦画画了一周才想出来思路,可能是博主太笨了。如果你相信自己的智商的话,可以先自己独立思考一下,找个比较结构比较深的json(没有现成的json对照的话自己都不知道要画啥),拿张纸画一画,找找思路。
下面介绍一下我的思路👇
空间利用思路
简单把问题拆解一下,海曙区
覆盖余杭区
的根本原因就是宁波市的起始坐标startX
离杭州市的结束坐标
太近了,或者说,在上面的逻辑中我没有正确的计算杭州市的结束坐标
,正确计算应该是将杭州市及所有子孙节点看成一个整体来计算,即:
结束坐标 = 开始坐标startX + 杭州市包含子孙节点整个区块的宽度
计算子孙节点还得考虑到一个递归的问题,想要计算出区块浙江省
的宽度,就要先计算出区块杭州市
的宽度,想要计算出区块杭州市
的宽度,就要先计算出区块拱墅区
的宽度,拱墅区没有子节点了,就直接向上返回自己文本节点宽度就可以了。
然后再由区块杭州市
将所有子节点上报的宽度加起来作为整个区块的宽度,再上报给上层浙江省
,浙江省
拿到杭州市
的宽度,自然就能推算出宁波市
的起始坐标startX
了。
光说可能不容易理解,看代码吧👇
定义类型
// type.ts
/**
* 新增三个类型
* NodePosition: 定义一个单位的位置信息和宽高还有类型
* 'text'类型为文本节点,'block'类型为区块节点
*
* TextPosition:文本节点,继承NodePosition
*
* BlockPosition:区块节点,继承NodePosition,title 属性为这个区块的头部节点
*/
export interface NodePosition {
startX: number;
startY: number;
height: number;
width: number;
type: "text" | "block";
}
export interface TextPosition extends NodePosition {
type: "text";
// 文本节点内容
title: string;
}
export interface BlockPosition extends NodePosition {
type: "block";
// 区块节点头部的文本,以杭州市为例,"杭州市"这三个字作为一个文本节点,放在title上
title: TextPosition;
// “拱墅区”、“余杭区”作为子元素,放在children里面
children: NodePosition[];
}
这里定义三个类型,是为了根据json树
生成一个带有位置信息的node树
,然后再单独去渲染这个node树
,为什么这么做后面再说。
生成node树
// 一个区块/节点的起始位置信息
interface StartPosition {
startX: number;
startY: number;
}
// 生成节点树
function renderNode(
startPosition: StartPosition,
treeNode: TreeNode
): TextPosition | BlockPosition {
// 有子节点,定义为block节点
if (treeNode.children && treeNode.children.length > 0) {
// 区块宽度,累加
let blockWidth = 0;
// 区块高度,取子节点最大值
// 这里给一个默认的值
let blockHeight = nodeHeight + nodeMarginY + nodeHeight;
// 画节点时保存下一个节点的起始位置
let lastItemPosition = {
// 第一个子节点,起始 startX 就是上层传进来的 startX
nextStartX: startPosition.startX,
// 子节点的起始 startY 应该预留出头部的空间
// 比如 杭州市的块节点,拱墅区和余杭区的 startY 应该在文本节点 “杭州市”下面
nextStartY: startPosition.startY + nodeHeight + nodeMarginY,
};
// 存储子节点的数组
const children: NodePosition[] = [];
// 遍历子节点
treeNode.children.forEach((e) => {
// 递归生成子节点的位置信息,拿到位置信息去处理下一个子节点
const childPosition = renderNode(
{
startX: lastItemPosition.nextStartX,
startY: lastItemPosition.nextStartY,
},
e
);
// 这里取所有子节点的最大高度,作为区块的高度
if (childPosition.height > blockHeight) {
blockHeight = childPosition.height;
}
// 累加子节点宽度,作为区块宽度
// 这里最后会多加出来一个nodeMarginX,最后要减去
blockWidth += childPosition.width + nodeMarginX;
// 给下一个兄弟节点准备起始位置
lastItemPosition = {
// 兄弟节点的startY都是一样的,不需要动
nextStartY: childPosition.startY,
// 兄弟节点的startX要累加前当前的宽度,再预留出点边距
nextStartX: childPosition.startX + childPosition.width + nodeMarginX,
};
// 将位置信息添加到数组
children.push(childPosition);
});
// 返回数据给上一层
return {
startX: startPosition.startX,
startY: startPosition.startY,
type: "block",
// 减去一个多余的nodeMarginX
width: blockWidth - nodeMarginX,
height: blockHeight,
children: children,
title: {
startX: startPosition.startX,
startY: startPosition.startY,
type: "text",
title: treeNode.title,
width: nodeWidth,
height: nodeHeight,
},
};
}
// 没有子节点,就是普通的文本节点,直接返回
return {
startX: startPosition.startX,
startY: startPosition.startY,
type: "text",
width: nodeWidth,
height: nodeHeight,
title: treeNode.title,
};
}
代码稍长,不过我都写了注释。
绘制node树
function draw(node: NodePosition) {
// 文本节点直接绘制
if (node.type === "text") {
drawTextItem(node as TextPosition);
} else {
const blockNode = node as BlockPosition;
// 区块节点,先绘制区块头部的文本节点
drawTextItem(blockNode.title);
// 再绘制子节点
blockNode.children.forEach((e) => {
draw(e);
});
}
}
对比生成树节点代码,这部分显得非常简单了,反正位置信息都是现成的,只要无脑画画画就ok了。
调用下看看效果👇
const node = renderNode(
{
startX: 10,
startY: 10,
},
tree
);
draw(node);
🎉看起来还不错,
宁波市
这次能跟在杭州市
后面渲染了,没再出现上面覆盖渲染的情况。
但是现在又出现了之前碰到的居中问题了,而这也是我选择要先生成node树
再单独渲染的原因。如果你觉着看到这里有点吃力的话,可以先休息一下再接着往下看。如果你觉着还ok的话,那就再看看我如何处理居中的吧😉。
居中
思路
居中实现我也思考挺长时间,因为我不太知道我是想要啥样的效果, 纠结挺长时间我最后得出两种想法:
- 区块的头部,根据区块整体宽度做居中移动
- 区块的头部,根据子元素位置做居中移动
第一种简单粗暴,区块整体宽度是有的,直接移动就可以了,但是如果树元素分布不均匀的话,就挺丑。
第二种又涉及到递归,还是要先从树最下面的元素开始算,最后一层层递归居中到顶层根元素,也是挺绕。
这两种我都实现过,最后选择了第二种。
代码
// 摇树一下
function shakeTree(nodePosition: NodePosition) {
// 普通文本节点没有居中的必要,直接返回坐标
if (nodePosition.type === "text") {
return nodePosition.startX;
}
// 块节点,要对头部的文本节点进行居中操作
const blockNode = nodePosition as BlockPosition;
// 保存子节点第一个和最后一个的居中StarX
let firstChildCenterStarX = 0,
lastChildCenterStartX = 0;
// 遍历递归子节点
blockNode.children.forEach((e, index) => {
// 先摇动子节点,让最下层的子节点完成居中,然后依次处理上层
const centerStartX = shakeTree(e);
if (index === 0) {
firstChildCenterStarX = centerStartX;
}
if (index === blockNode.children.length - 1) {
lastChildCenterStartX = centerStartX;
}
});
// 区块标题的居中点
// 即 `最后一个子节点` 与 `第一个子节点的位置` 这段距离 的 `中间位置`
const newStartX =
(lastChildCenterStartX - firstChildCenterStarX) / 2 + firstChildCenterStarX;
// 赋值给区块的头部文本
blockNode.title.startX = newStartX;
// 将这个区块的头部文本节点的居中位置返回给上层
// 上层拿到后再做上层的居中操作
return newStartX;
}
调用看看效果👇
//...省略其他代码
const node = renderNode(
{
startX: 10,
startY: 10,
},
tree
);
shakeTree(node);
draw(node);
节点分布不均匀的情况👇
效果还可以,接下来就剩最后一步了,给这些文本节点画上线,不过这次不需要递归调用了。
连接线
思路
思路挺简单朴素,就是从区块的头下面中心的位置,画到子元素节点上面中心的位置。所有元素节点位置宽高信息都有了,随便画画就可以了。
画线代码
// 判断是否文本节点,告诉编辑器e是TextPosition类型
// 用的时候就不需要 e as TextPosition 强转
function isTextNode(e: NodePosition): e is TextPosition {
return e.type === "text";
}
// 判断是否块本节点,告诉编辑器e是BlockPosition类型
// 用的时候就不需要 e as BlockPosition 强转
function isBlockNode(e: NodePosition): e is BlockPosition {
return e.type === "block";
}
// 画线
function drawLine(block: BlockPosition) {
const ctx = getCtx();
ctx.beginPath();
// 计算画线起点
// startX,是区块头部文本节点的中心位置
const lineStartX = block.title.startX + block.title.width / 2;
// startY,是区块头部文本节点的底部
const lineStartY = block.title.startY + block.title.height;
// Y转折点,如果有多个子节点,需要在中间分叉
const halfY = lineStartY + nodeMarginY / 2;
// 画笔移动起点
ctx.moveTo(lineStartX, lineStartY);
// 先把线画到转折点
ctx.lineTo(lineStartX, halfY);
// 把转折点和子元素连接起来
block.children.forEach((e) => {
// 画完一个子节点后,需要重新回到分叉点,第一次不需要
ctx.moveTo(lineStartX, halfY);
// 如果是文本节点
if (isTextNode(e)) {
// 直接去文本中心位置
const childCenterX = e.startX + e.width / 2;
// 画个转折点
ctx.lineTo(childCenterX, halfY);
// 画到文本上方
ctx.lineTo(childCenterX, e.startY);
return;
}
// 如果是块节点
if (isBlockNode(e)) {
// 用上面的方法判断类型,可以隐形的让 e 转为 BlockPosition 类型,这里可以直接取title了
const childTitle = e.title;
// 取到头部的中心点位置
const childTitleCenterX = childTitle.startX + childTitle.width / 2;
// 画个转折点
ctx.lineTo(childTitleCenterX, halfY);
// 画到头部文本上方
ctx.lineTo(childTitleCenterX, childTitle.startY);
}
});
// 最后别忘了stroke
ctx.stroke();
}
调用
/**
* 绘制node树
*/
function draw(node: NodePosition) {
// 文本节点直接绘制
if (node.type === "text") {
drawTextItem(node as TextPosition);
} else {
const blockNode = node as BlockPosition;
// 区块节点,先绘制区块头部的文本节点
drawTextItem(blockNode.title);
// 再绘制子节点
blockNode.children.forEach((e) => {
draw(e);
});
// 主要是块头部画到子节点
drawLine(blockNode);
}
}
看看效果
看起来不错,就是有点挤,把
nodeMarginY
的值设置大一点会更好看。
我把
nodeMarginY
改成了18。
点击事件
也许你的树需要一点增删改的操作,就需要用户直接点击树节点交互了,但我碰到的业务暂时不需要点击操作,所以我也就没写,如果你需要的话可以自行研究一下,这里讲一下大致思路。
其实就跟draw
方法差不多,我们已经根据json
生成了一个node树
,有所有节点的位置和长宽信息,那么就可以在点击事件进来时判断事件的x
和y
是否与树中的某个节点的位置信息
有交叉了,如果有的话,那么就可以接着往下递归查找自己的子节点是否有交叉,直到找到最后一个没有子节点的节点,即用户点击的节点。
举个例子来说:
如果用户点击的是拱墅区
,那么在比较点击事件与位置信息是否有交叉时,要从树的根节点浙江省
开始比较,发现有交叉,那么接着往下找,然后就找到杭州市
,接着往下找到拱墅区
,大体思路就是这个样子,不过也要注意区块头部
也能捕获点击事件。
总结
因为没有具体的参照物,很多时候我只在脑子里空想,忘了参考一下市面上已有的图表图片了,如果看着现成的图思考起来会轻松很多,感觉具体的问题比模糊的问题好解决很多。
绘制思路我没有参考过市面上的库之类的,经验主要还是来自早年做安卓时用到的自定义布局view的经历。如果有更好的思路,可以在评论区讨论一下🤞。
全部代码
切换到script
标签看👇
转载自:https://juejin.cn/post/7207094325895757861