使用canvas绘制树形状图
基本实现效果:
实现方案
1.将效果中的每个小方块通过div循环实现。通过规律性循环的div结构遍历获取到每个需要连接的点位。然后通过canvas画出连线以及运动点位。
<div ref="TreeRef" class="broadcast-item-box">
<level-box v-for="item in treeData" :key="item.equipmentCode" :data="item"></level-box>
</div>
<canvas ref="CanvasRef" :height="canvasHeight" :width="canvasWidth" class="canvas-box"></canvas>
<canvas ref="MoveCanvasRef" :height="canvasHeight" :width="canvasWidth" class="canvas-move-box"></canvas>
通过position:absolute控制div的元素和canvas保持重叠。
这里用了两个canvas,一个canvas用来画固定的内容,一个用来绘制动画的内容(需要不断重绘实现动画)
level-box组件自我回调,方便遍历寻找子元素
<div class="level-box">
<div class="level-item">
<div class="item-default"></div>
</div>
<div class="level-child">
<level-box></level-box>
</div>
</div>
2.定义一些公共变量
const canvasWidth = ref<number>(0);
const canvasHeight = ref<number>(0);
let ctx: CanvasRenderingContext2D;
let ctxMove: CanvasRenderingContext2D;
//canvas的坐标数据
let canvasRect: DOMRect;
//连线层
let treeLineList: LineCanvasItem[] = [];
//动画层
let animationList: BallMoveCanvasItem[] = [];
//球大小
const ballSize: number = 10;
//球的速度
const ballStep: number = 0.003;
//球的长度
const ballLineSize: number = 25;
//球的尾端缩放大小
const ballLineEndSize: number = 0.8;
interface CanvasItem {
x: number;
y: number;
}
interface MoveCanvasItem {
x: number;
y: number;
t: number;
}
interface LineCanvasItem {
form: DomPlace;
to: DomPlace;
data: TreeItem;//业务参数
}
interface DomPlace {
x: number;
y: number;
width: number;
height: number;
}
interface BallMoveCanvasItem {
form: CanvasItem;
to: CanvasItem;
ball: MoveCanvasItem[];
bezier: CanvasItem;
data: TreeItem;
}
3.通过mapTree回调获取TreeRef中元素
getBoundingClientRect()方法可以获取到dom元素的相对于视窗的位置(top,lef,right,bottom,width,height)。
//连线层
const mapTree = (treeDom: HTMLCollection, treeData: TreeItem[]) => {
for (let i = 0; i < treeDom.length; i++) {
let item = treeDom[i];
if (item.children && item.children.length > 0) {
if (item.children.length > 1 && item.children[1].children.length > 0) {
for (let j = 0; j < item.children[1].children.length; j++) {
let form;
let to;
form = item.children[0].children[0].children[2].children[0].getBoundingClientRect();
to = item.children[1].children[j].children[0].children[0].children[0].children[0].getBoundingClientRect();
treeLineList.push({
form,
to,
data: treeData[i].sonList[j],
});
}
if (item.children[1].children.length > 0) {
mapTree(item.children[1].children, treeData[i].sonList);
}
}
}
}
};
4.开始构建获取canvas元素
const drawCanvas = async () => {
let tree = TreeRef.value;
if (tree) {
treeLineList = [];
animationList = [];
await mapTree(tree.children, treeData.value);
let canvas = CanvasRef.value;
if (canvas) {
ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.clearRect(0, 0, canvas.width, canvas.height);//清空画布
await makeTreeLine(treeLineList);//绘制线的canvas
}
let moveCanvas = MoveCanvasRef.value;
if (moveCanvas) {
ctxMove = moveCanvas.getContext("2d") as CanvasRenderingContext2D;
await makePointMove(animationList);//绘制动画的canvas
}
} else {
return;
}
};
5.绘制tree的线
const makeTreeLine = async (arr: LineCanvasItem[]) => {
arr.forEach((item: LineCanvasItem) => {
let form = {
x: item.form.x + item.form.width,//起始点的x坐标
y: item.form.y + item.form.height / 2,//起始点的y坐标
};
let to = {
x: item.to.x,//终点的x坐标
y: item.to.y + item.to.height / 2,//终点的y坐标
};
let bezier = {//二次贝塞尔曲线点位
x: form.x + (to.x - form.x) / 3,
y: 0,
};
if (to.y > form.y) {//判断终点相对于起始点的上方/下方
bezier.y = to.y - (to.y - form.y) / 10;
} else {
bezier.y = to.y + (form.y - to.y) / 10;
}
//创建一个渐变色
let linearGradient = ctx.createLinearGradient(form.x, form.y + to.y / 2, to.x, form.y + to.y / 2);
linearGradient.addColorStop(0, "#29eae3");
linearGradient.addColorStop(1, "#05ffaa");
//判断当前的线是否需要动画
if (item.data.isLinkOpen) {
animationList.push({ data: item.data, form, to, bezier, ball: [] });
}
ctx.strokeStyle = linearGradient;//线的颜色
ctx.lineWidth = 1;//线宽
ctx.beginPath();
ctx.moveTo(form.x, form.y);
ctx.quadraticCurveTo(bezier.x, bezier.y, to.x, to.y);//绘制二次贝塞尔曲线
ctx.stroke();
});
};
//绘制动画
- 基本思路:如果只需要一个球那么就不需要球的队列。如果需要球的尾迹。目前普遍查到的是通过覆盖整体的背景来实现。但是由于项目没有统一底色,没办法通过覆盖整体背景来实现。
- 通过一个队列来绘制多个球,并逐渐重制球的透明度逐渐消失。
一个尾迹长度的数组,不断的往数组前插入一个新的元素,在满足长度后每次插入一个都删掉最后一个 - 初始/抵达终点的时候将队列清空,并放入起始点的球。
- 在下一帧的时候在数组头插入一个新的球元素。
- 每次更新帧的时候给球元素设置一个新的颜色值(改变透明度),以及缩放大小。
- 如果数组的长度达到设定的尾迹长度,就删掉最后一个球元素。
- 更新动画使用requestAnimationFrame(),可以防止出现移除以及同步帧率。
const makePointMove = async (arr: BallMoveCanvasItem[]) => {
ctxMove.clearRect(0, 0, canvasWidth.value, canvasHeight.value);//每次调用时清空画布
arr.forEach((item: BallMoveCanvasItem) => {
computedPosition(item.form, item.to, item.ball, item.bezier);//绘制帧
});
requestAnimationFrame(() => {//更新动画
makePointMove(arr);
});
};
const computedPosition = (form: CanvasItem, to: CanvasItem, ball: MoveCanvasItem[], bezier: CanvasItem) => {
if (ball.length === 0) {
ball.push({
x: form.x,
y: form.y,
t: 0,
});
}
ball.forEach((item: MoveCanvasItem, index: number) => {
//绘制一个球
let startColor = "rgba(41,234,227," + (1 / ballLineSize) * (ballLineSize - index) + ")";//颜色起始点的透明度。
let endColor = "rgba(5,255,170," + (1 / ballLineSize) * (ballLineSize - index) + ")";//颜色终止点的透明度。
let size = ballSize - ballSize * (((1 - ballLineEndSize) / ballLineSize) * index);//计算球的大小
//渐变色
let linearGradient = ctxMove.createLinearGradient(item.x - ballSize, item.y, item.x + ballSize, item.y);
linearGradient.addColorStop(0, startColor);
linearGradient.addColorStop(1, endColor);
ctxMove.beginPath();
ctxMove.fillStyle = linearGradient;//球的颜色
ctxMove.arc(item.x, item.y, size, 0, 2 * Math.PI);//绘制球
ctxMove.fill();
});
//计算下一次球的位置(二次贝塞尔曲线)
let newBall = {
x: Math.pow(1 - ball[0].t, 2) * form.x + 2 * ball[0].t * (1 - ball[0].t) * bezier.x + Math.pow(ball[0].t, 2) * to.x,
y: Math.pow(1 - ball[0].t, 2) * form.y + 2 * ball[0].t * (1 - ball[0].t) * bezier.y + Math.pow(ball[0].t, 2) * to.y,
t: ball[0].t + ballStep,
};
//处理球队列
ball.unshift(newBall);
if (ball.length > ballLineSize) {
ball.pop();
}
//重制球的位置
if (ball[0].x >= to.x) {
ball.splice(0, ballLineSize);
}
};
一些问题的处理
1.无法获取到子组件的元素。
解决方案:将获取元素的方法放到nextTick方法中调用。
2.动画的自我回调出现栈溢出。
解决方案:通过使用requestAnimationFrame()自我回调实现同时达成帧率同步。
3.canvas元素大小与实际画布大小的问题导致拉伸/缩放/与dom元素无法对应。
解决方案:最后可以实现canvas画布大小根据页面自动适配。
<canvas ref="CanvasRef" :height="canvasHeight" :width="canvasWidth" class="canvas-box"></canvas><!--给canvas元素绑定height和width-->
let timeFlag: boolean = false;
onMounted(() => {
//监测页面大小改变,设定0.5秒触发一次更新画布大小。
window.onresize = () => {
if (timeFlag) return;
timeFlag = true;
setTimeout(() => {
updateCanvas();
timeFlag = false;
}, 500);
};
});
//更新重绘画布
const updateCanvas = async () => {
//获取到覆盖canvas的div大小或者其他方式获取到所需要的实际画布大小
canvasHeight.value = (window.innerHeight - 2 - (window.innerWidth / 1920) * 170) ;
canvasWidth.value = window.innerWidth * 0.288 ;
let canvas = CanvasRef.value;
let moveCanvas = MoveCanvasRef.value;
if (canvas) {
canvas.height = canvasHeight.value;
canvas.width = canvasWidth.value;
canvasRect = canvas.getBoundingClientRect();//获取canvas的相对位置。用于计算坐标时候用
}
if (moveCanvas) {
moveCanvas.height = canvasHeight.value;
moveCanvas.width = canvasWidth.value;
}
await nextTick(() => drawCanvas());//开始绘制canvas
};
4.canvas绘制出来的内容有时候很模糊。
解决方案:通过放大画布至2倍大小,同时把获取到需要定位的dom元素的大小也放大2倍。然后通过css缩放至0.5倍(需要注意的是线的粗细以及球的大小等内容也需要放大)。
最终代码
<div class="broadcast-box">
<div ref="TreeRef" class="broadcast-item-box">
<level-box v-for="item in treeData" :key="item.equipmentCode" :data="item"></level-box>
</div>
<canvas ref="CanvasRef" :height="canvasHeight" :width="canvasWidth" class="canvas-box"></canvas>
<canvas ref="MoveCanvasRef" :height="canvasHeight" :width="canvasWidth" class="canvas-move-box"></canvas>
</div>
<div class="level-box">
<div class="level-item">
<div class="item-default"></div>
</div>
<div class="level-child">
<level-box></level-box>
</div>
</div>
<script lang="ts" setup>
import { nextTick, onMounted, ref } from "vue";
import { getSitesTree } from "@/api/daily";
const treeData = ref<TreeItem[]>([]);
const CanvasRef = ref<HTMLCanvasElement>();
const MoveCanvasRef = ref<HTMLCanvasElement>();
const TreeRef = ref<HTMLDivElement>();
const canvasWidth = ref<number>(0);
const canvasHeight = ref<number>(0);
let timeFlag: boolean = false;
let ctx: CanvasRenderingContext2D;
let ctxMove: CanvasRenderingContext2D;
//canvas的坐标数据
let canvasRect: DOMRect;
//连线层
let treeLineList: LineCanvasItem[] = [];
//动画层
let animationList: BallMoveCanvasItem[] = [];
//球大小
const ballSize: number = 10;
//球的速度
const ballStep: number = 0.003;
//球的长度
const ballLineSize: number = 25;
//球的尾端缩放大小
const ballLineEndSize: number = 0.8;
onMounted(() => {
getCityTree();
//监测页面大小改变
window.onresize = () => {
if (timeFlag) return;
timeFlag = true;
setTimeout(() => {
updateCanvas();
timeFlag = false;
}, 500);
};
});
const getCityTree = async () => {
let res: any = await getTreeData();
if (res.success && res.data.length > 0) {
treeData.value = res.data;
await nextTick(() => {
updateCanvas();
});
}
};
//更新重绘画布
const updateCanvas = async () => {
canvasHeight.value = (window.innerHeight - 2 - (window.innerWidth / 1920) * 170) * 2;
canvasWidth.value = window.innerWidth * 0.288 * 2;
let canvas = CanvasRef.value;
let moveCanvas = MoveCanvasRef.value;
if (canvas) {
canvas.height = canvasHeight.value;
canvas.width = canvasWidth.value;
canvasRect = canvas.getBoundingClientRect();
}
if (moveCanvas) {
moveCanvas.height = canvasHeight.value;
moveCanvas.width = canvasWidth.value;
}
await nextTick(() => drawCanvas());
};
const drawCanvas = async () => {
let tree = TreeRef.value;
if (tree) {
treeLineList = [];
animationList = [];
await mapTree(tree.children, treeData.value);
let canvas = CanvasRef.value;
if (canvas) {
ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.clearRect(0, 0, canvas.width, canvas.height);
await makeTreeLine(treeLineList);
}
let moveCanvas = MoveCanvasRef.value;
if (moveCanvas) {
ctxMove = moveCanvas.getContext("2d") as CanvasRenderingContext2D;
await makePointMove(animationList);
}
} else {
return;
}
};
//遍历tree元素寻找起始点以及终点元素的坐标
const mapTree = (treeDom: HTMLCollection, treeData: TreeItem[]) => {
for (let i = 0; i < treeDom.length; i++) {
let item = treeDom[i];
if (item.children && item.children.length > 0) {
if (item.children.length > 1 && item.children[1].children.length > 0) {
for (let j = 0; j < item.children[1].children.length; j++) {
let form = item.children[0].children[0].children[2].children[0].getBoundingClientRect();;
let to = item.children[1].children[j].children[0].children[0].children[0].children[0].getBoundingClientRect();;
treeLineList.push({
form: {
x: (form.x - canvasRect.x) * 2,
y: (form.y - canvasRect.y) * 2,
width: form.width * 2,
height: form.height * 2,
},
to: {
x: (to.x - canvasRect.x) * 2,
y: (to.y - canvasRect.y) * 2,
width: to.width * 2,
height: to.height * 2,
},
data: treeData[i].sonList[j],
});
}
if (item.children[1].children.length > 0) {
mapTree(item.children[1].children, treeData[i].sonList);
}
}
}
}
};
//绘制tree的线
const makeTreeLine = async (arr: LineCanvasItem[]) => {
arr.forEach((item: LineCanvasItem) => {
let linearGradient;
let form = {
x: item.form.x + item.form.width,
y: item.form.y + item.form.height / 2,
};
let to = {
x: item.to.x,
y: item.to.y + item.to.height / 2,
};
let bezier = {
x: form.x + (to.x - form.x) / 3,
y: 0,
};
if (to.y > form.y) {
bezier.y = to.y - (to.y - form.y) / 10;
} else {
bezier.y = to.y + (form.y - to.y) / 10;
}
linearGradient = ctx.createLinearGradient(form.x, form.y + to.y / 2, to.x, form.y + to.y / 2);
if (item.data.isLinkOpen) {
linearGradient.addColorStop(0, "#29eae3");
linearGradient.addColorStop(1, "#05ffaa");
animationList.push({ data: item.data, form, to, bezier, ball: [] });
} else {
linearGradient.addColorStop(0, "#afbcc5");
linearGradient.addColorStop(1, "#a3a4a6");
}
ctx.strokeStyle = linearGradient;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(form.x, form.y);
ctx.quadraticCurveTo(bezier.x, bezier.y, to.x, to.y);
ctx.stroke();
});
};
const makePointMove = async (arr: BallMoveCanvasItem[]) => {
ctxMove.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
arr.forEach((item: BallMoveCanvasItem) => {
computedPosition(item.form, item.to, item.ball, item.bezier);
});
requestAnimationFrame(() => {
makePointMove(arr);
});
};
const computedPosition = (form: CanvasItem, to: CanvasItem, ball: MoveCanvasItem[], bezier: CanvasItem) => {
if (ball.length === 0) {
ball.push({
x: form.x,
y: form.y,
t: 0,
});
}
ball.forEach((item: MoveCanvasItem, index: number) => {
//绘制一个球
let linearGradient = ctxMove.createLinearGradient(item.x - ballSize, item.y, item.x + ballSize, item.y);
let startColor = "rgba(41,234,227," + (1 / ballLineSize) * (ballLineSize - index) + ")";
let endColor = "rgba(5,255,170," + (1 / ballLineSize) * (ballLineSize - index) + ")";
let size = ballSize - ballSize * (((1 - ballLineEndSize) / ballLineSize) * index);
linearGradient.addColorStop(0, startColor);
linearGradient.addColorStop(1, endColor);
ctxMove.beginPath();
ctxMove.fillStyle = linearGradient;
ctxMove.arc(item.x, item.y, size, 0, 2 * Math.PI);
ctxMove.fill();
});
//计算下一次球的位置
let newBall = {
x: Math.pow(1 - ball[0].t, 2) * form.x + 2 * ball[0].t * (1 - ball[0].t) * bezier.x + Math.pow(ball[0].t, 2) * to.x,
y: Math.pow(1 - ball[0].t, 2) * form.y + 2 * ball[0].t * (1 - ball[0].t) * bezier.y + Math.pow(ball[0].t, 2) * to.y,
t: ball[0].t + ballStep,
};
//处理球队列
ball.unshift(newBall);
if (ball.length > ballLineSize) {
ball.pop();
}
//重制球的位置
if (ball[0].x >= to.x) {
ball.splice(0, ballLineSize);
}
};
</script>
<style lang="less" scoped>
.broadcast-box {
width: 100%;
height: 100%;
position: relative;
}
.broadcast-item-box,
.canvas-box,
.canvas-move-box {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.canvas-move-box {
z-index: 2;
}
.broadcast-item-box {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
z-index: 3;
}
.canvas-box,
.canvas-move-box {
transform-origin: 0 0;
transform: scale(0.5);
}
</style>