vue3+ts通过canvas实现曲线(贝塞尔曲线)链路图/树形图的动画效果

使用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>
  • 19
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值