用canvas画一个炫酷流动关系图

3,403 阅读5分钟

关系图由节点和连线组成,可以清晰展示实体与实体之间的联系。 最近整理一下以前的项目,发现了几年前写的一个流动关系图组件,挺好看的!教大家实现一下~

20241222_115213.gif

1.绘制节点

image.png

  • 节点数据格式
type NodeConfig = {
  //节点ID
  id: string;
  //x坐标
  x: number;
  //y坐标
  y: number;
  //字体大小
  fontSize: number;
  //边距
  padding: number;
  //文本内容
  text: string;
  //文本颜色
  fontColor: string;
  //节点颜色
  lineColor: string;
  //线宽
  lineWidth: number;  
  //文本宽度
  textWidth?: number;
  //上下左右包围框
  box?: { left: number; right: number; top: number; bottom: number };
  //四个中心点,左中,右中,上中,下中
  points?: number[][];
  //节点高度
  height?: number;
};
  • 绘制节点外框
ctx.shadowBlur = 0;
    ctx.strokeStyle = t.lineColor;
    ctx.lineWidth = t.lineWidth;

    const height = t.fontSize + t.padding * 2 + t.lineWidth * 2;
    const r = height * 0.5;
    //设置文本样式
    ctx.font = t.fontSize + 'px Arial';
    //文本宽度
    const textWidth = ctx.measureText(t.text).width || 0;
    //中心y坐标
    const cy = t.y + r;
    //左半圆心x坐标
    const cx = t.x - t.lineWidth;
    //右半圆心x坐标
    const cx1 = t.x + textWidth;
    ctx.beginPath();
    //左边半圆
    ctx.arc(cx, cy, r, 0.5 * Math.PI, 1.5 * Math.PI);
    //上边
    ctx.moveTo(cx, t.y);
    ctx.lineTo(cx1, t.y);
    //右边半圆
    ctx.arc(cx1, cy, r, 1.5 * Math.PI, 0.5 * Math.PI);
    //下边
    ctx.lineTo(cx, cy + r);
    //绘制外框
    ctx.stroke();
  • 绘制节点渐变
//从左到右的渐变
    const grd = ctx.createLinearGradient(cx - r, cy, cx1 + r, cy);
    const c = getColor(t.lineColor);
    grd.addColorStop(0, `rgba(${c.red},${c.green},${c.blue},0.8)`);
    grd.addColorStop(1, `rgba(${c.red},${c.green},${c.blue},0)`);
    ctx.fillStyle = grd;
    ctx.fill();
  • 绘制文本
 //设置字体颜色
    ctx.fillStyle = t.fontColor;
    //绘制字体
    ctx.fillText(t.text, t.x, cy + t.fontSize * 0.5 - t.lineWidth);
  • 缓存节点的一些信息,用于绘制连线
 //缓存一些信息
    t.textWidth = textWidth;
    t.height = height;
    //包围框范围
    t.box = {
      left: t.x - t.lineWidth - r,
      right: t.x + textWidth + r + t.lineWidth,
      top: t.y - t.lineWidth,
      bottom: t.y + height + t.lineWidth
    };
    //四个中心点位置
    t.points = this.getPoint(t);

    this.nodeMap[t.id] = t;

2.绘制连线和流动小球

  • 连线数据格式
type LineConfig = {
  //开始节点ID
  startId: string;
  //结束节点ID
  endId: string;
  //连线类型 'bezierCurve'贝塞尔曲线  'line'直线
  lineType: 'bezierCurve' | 'line';
  //连线颜色
  lineColor: string;
  //小球发光宽度
  blurWidth: number;
  //连线宽度
  lineWidth: number;
  //小球数量
  pointNum: number;
  //小球大小
  pointSize: number;
  //移动速度
  moveStep: number;
};
  • 计算节点的四个中心点
  //四个中心点,左中,右中,上中,下中
  getPoint(t: NodeConfig) {
    const height = t.height!;
    const r = height * 0.5;
    const textWidth = t.textWidth || 0;
    const box = t.box || {
      left: t.x - t.lineWidth - r,
      right: t.x + textWidth + r + t.lineWidth,
      top: t.y - t.lineWidth,
      bottom: t.y + height + t.lineWidth
    };
    return [
      [box.left, t.y + r],
      [box.right, t.y + r],
      [t.x + textWidth * 0.5, box.top],
      [t.x + textWidth * 0.5, box.bottom]
    ];
  }
  • 用节点的四个中心点计算距离,将最短的两个中心点作为连线的起点和终点
//根据起点和终点节点的中心点,计算最短距离的两点的中心点位置
  getStartEnd(start: NodeConfig, end: NodeConfig) {
    const s = start.points || this.getPoint(start);
    const e = end.points || this.getPoint(end);
    let min = Number.MAX_SAFE_INTEGER;
    let minS = 0,
      minE = 0;
    for (let i = 0; i < s.length; i++) {
      for (let j = 0; j < e.length; j++) {
        const d = this.getDistance(s[i], e[j]);
        if (min >= d) {
          min = d;
          minS = i;
          minE = j;
        }
      }
    }

    return {
      start: { x: s[minS][0], y: s[minS][1] },
      end: { x: e[minE][0], y: e[minE][1] }
    };
  }
  • 获取连线信息
const { start, end } = this.getStartEnd(startNode, endNode);
    //连线ID
    const lineId = point.startId + '-' + point.endId;
    //小球运动变量,加上小球均分位置,递增该变量形成运动小球
    const move = (this.infoMap[lineId] || 0) + point.moveStep;
     //设置连线样式
    const c = getColor(point.lineColor);
    ctx.beginPath();
    ctx.shadowBlur = 0;
    //连线线宽
    ctx.lineWidth = point.lineWidth;
    //连线颜色
    ctx.strokeStyle = `rgba(${c.red},${c.green},${c.blue},0.3)`;

绘制直线连线

  • 绘制直连线
 else if (point.lineType === 'line') {
      //移动到起点
      ctx.moveTo(start.x, start.y);
      //绘制直线
      ctx.lineTo(end.x, end.y);
      ctx.stroke();
  • 绘制沿直线的小球

直线公式:Start和End是两个开始点和结束点,t是沿直线的进度变量,范围是[0,1]

image.png

  • 公式中Start,End 点分别代入对应点的x坐标或y坐标可计算出沿直线的坐标
//均分连线绘制小球
      const unit = 1 / point.pointNum;
      //两点x坐标范围
      const xSize = end.x - start.x;
      //两点y坐标范围
      const ySize = end.y - start.y;

      //小球发光宽度
      ctx.shadowBlur = point.blurWidth;
      //小球发光颜色
      ctx.shadowColor = point.lineColor;
      //小球颜色
      ctx.fillStyle = point.lineColor;

      for (let i = 0; i <= 1; i = i + unit) {
        //循环移动
        const s = (i + move) % 1;
        //计算直线中小球的坐标
        const x = start.x + xSize * s;
        const y = start.y + ySize * s;
        //绘制小球
        ctx.beginPath();
        ctx.arc(x, y, point.pointSize, 0, 2 * Math.PI);
        ctx.fill();
      }
  • move记录当前小球运动变量,不断递增,范围[0,1],加上小球均分位置i,(i + move)改变小球位置,形成流动。(i + move) % 1通过取模让小球在连线上循环。

20241222_154829.gif

绘制贝塞尔曲线连线

  • 绘制贝塞尔曲线
    //连线颜色
    const c = getColor(point.lineColor);
    //三次贝塞尔曲线
    if (point.lineType === 'bezierCurve') {
    const cx = (start.x + end.x) * 0.5;
      //控制点1
      const p0 = {
        x: cx,
        y: start.y
      };
      //控制点2
      const p1 = {
        x: cx,
        y: end.y
      };

      //移动到起点
      ctx.moveTo(start.x, start.y);
      //绘制曲线
      ctx.bezierCurveTo(cx, start.y, cx, end.y, end.x, end.y); 
      ctx.stroke();
  • 绘制沿贝塞尔曲线运动的小球

    • 三次贝塞尔曲线公式:Start和End是两个开始点和结束点,P1和P2是两个控制点,t是沿贝塞尔曲线的进度变量,范围是[0,1]

image.png

  • 公式中Start,End,P1,P2点分别代入对应点的x坐标或y坐标可计算出沿贝塞尔曲线的坐标
//均分连线绘制小球
      const unit = 1 / point.pointNum;
         //小球发光宽度
        ctx.shadowBlur = point.blurWidth;
        //小球发光颜色
        ctx.shadowColor = point.lineColor;
        //小球颜色
        ctx.fillStyle = point.lineColor;
      for (let i = 0; i <= 1; i = i + unit) {
      //循环移动
        const s = (i + move) % 1;
        const a = 1 - s;
        //计算三次贝塞尔曲线中小球的坐标
        const x =
          start.x * Math.pow(a, 3) +
          3 * s * Math.pow(a, 2) * p0.x +
          3 * Math.pow(s, 2) * a * p1.x +
          Math.pow(s, 3) * end.x;
        const y =
          start.y * Math.pow(a, 3) +
          3 * s * Math.pow(a, 2) * p0.y +
          3 * Math.pow(s, 2) * a * p1.y +
          Math.pow(s, 3) * end.y;
          
        ctx.beginPath();     
        //绘制小球
        ctx.arc(x, y, point.pointSize, 0, 2 * Math.PI);
        ctx.fill();
      }

20241222_144134 00_00_00-00_00_30.gif

3.移动节点

  • 添加鼠标动作
 this.canvas.addEventListener('pointerdown', this.onMouseDown.bind(this));
    this.canvas.addEventListener('pointermove', this.onMouseMove.bind(this));
    this.canvas.addEventListener('pointerup', this.onMouseUp.bind(this));
  • 鼠标按下动作
  //鼠标按下,遍历节点,获取选中节点
  onMouseDown(e: PointerEvent) {
    const x = e.offsetX;
    const y = e.offsetY;
//从后往前遍历节点,后面添加的节点在上面
    for (let i = this.nodes.length - 1; i >= 0; i--) {
      const box = this.nodes[i].box!;
      //鼠标在节点范围内
      if (x >= box.left && x <= box.right && y >= box.top && y <= box.bottom) {
        //设置选中节点ID
        this.targetId = this.nodes[i].id;
        //开启移动
        this.isMove = true;
        //设置鼠标样式
        this.canvas.style.cursor = 'move';
        break;
      }
    }
  }
  • 鼠标移动动作
//鼠标移动,修改节点的坐标
  onMouseMove(e: PointerEvent) {    
    if (this.isMove && this.targetId) {
      const node = this.nodeMap[this.targetId];
      //将鼠标位置设置成节点位置中心位置
      node.x = e.offsetX - node.textWidth! * 0.5;
      node.y = e.offsetY - node.height! * 0.5;
    }
  }
  • 鼠标抬起动作
 //鼠标抬起
  onMouseUp() {
    //置空选中节点ID
    this.targetId = '';
    //关闭移动
    this.isMove = false;
    //鼠标样式恢复默认
    this.canvas.style.cursor = 'default';
  }

20241222_114753.gif

4.使用流动关系图

const cLine = new CanvasLines({
  //画布DOM
  el: document.getElementById('myCanvas') as HTMLCanvasElement,
  //背景颜色
  bg: '#505050',
  //画布大小
  height: 800,
  width: 800,
  //节点数据
  nodes: [
    {
      id: 'a',
      x: 100,
      y: 50,
      text: '开始节点A',
      fontSize: 14,
      padding: 4,
      fontColor: 'white',
      lineColor: '#b1e2f1',
      lineWidth: 2
    },
    {
      id: 'b',
      x: 600,
      y: 250,
      text: '结束节点B',
      fontSize: 14,
      padding: 4,
      fontColor: 'white',
      lineColor: '#b1e2f1',
      lineWidth: 2
    },
    {
      id: 'c',
      x: 500,
      y: 400,
      text: '开始节点C',
      fontSize: 14,
      padding: 4,
      fontColor: 'white',
      lineColor: '#ffd700',
      lineWidth: 2
    },
    {
      id: 'd',
      x: 200,
      y: 650,
      text: '结束节点D',
      fontSize: 14,
      padding: 4,
      fontColor: 'white',
      lineColor: '#ffd700',
      lineWidth: 2
    },
    {
      id: 'e',
      x: 500,
      y: 650,
      text: '结束节点E',
      fontSize: 14,
      padding: 4,
      fontColor: 'white',
      lineColor: '#ffd700',
      lineWidth: 2
    }
  ],
  //连线数据
  lines: [
    {
      startId: 'a',
      endId: 'b',
      lineType: 'bezierCurve',
      lineColor: '#b1e2f1',
      blurWidth: 10,
      lineWidth: 4,

      pointNum: 5,
      pointSize: 6,
      moveStep: 0.005
    },
    {
      startId: 'c',
      endId: 'd',
      lineType: 'line',
      lineColor: '#ffd700',
      blurWidth: 10,
      lineWidth: 4,
      pointNum: 5,
      pointSize: 6,
      moveStep: 0.005
    },
    {
      startId: 'c',
      endId: 'e',
      lineType: 'line',
      lineColor: '#ffd700',
      blurWidth: 10,
      lineWidth: 4,
      pointNum: 5,
      pointSize: 6,
      moveStep: 0.005
    }
  ]
});
//绘制关系图
cLine.draw();

20241222_115213.gif

GitHub地址

https://github.com/xiaolidan00/demo-vite-ts