Canvas绘制自定义流动路径

整理汇总一下之前用canvas画过的流动路径:流动折线路径,流动小球折线路径,流动小球贝塞尔曲线。

1. 流动折线路径

流动折线配置

ts 复制代码
type RunningPathType = {
  id: string | number;
  //像素点
  path: [number, number][];
  //流动速度:像素/秒
  speed: number;
  //线宽
  strokeWidth?: number;
  //线颜色
  strokeColor?: string | string[];
  //实线长度
  stepWidth: number;
  //间隔长度
  dashWidth: number;
  //线段两端
  lineCap?: 'round' | 'butt' | 'square';
  //透明度
  opacity?: number;
  //反向流动
  reverse?: boolean;
};

整理路径点信息:

  • 折线总长度
  • 计算每段折线的长度和开始结束距离
  • 每段单位实线和虚线的长度
  • 虚线的段数
ts 复制代码
const props = this.props;
    const p = props.path;
    //路径点数量小于2不绘制
    if (p.length < 2) return;
    //折线总长度
    let sum = 0;
    let pre = 0;
    //计算每段折线的长度和开始结束距离
    const pathList: Array<{
      start: [number, number];
      startV: number;
      end: [number, number];
      endV: number;
      size: number;
    }> = [];
    for (let i = 1; i < p.length; i++) {
      const start = p[i - 1];
      const end = p[i];
      const d = getDistance(start, end);
      sum += d;
      pathList.push({
        start,
        end,
        startV: pre,
        endV: sum,
        size: sum - pre
      });
      pre = sum;
    }
    //每段实线和虚线的长度
    const unit = props.dashWidth + props.stepWidth;
    //虚线的段数
    const num = Math.ceil(sum / unit);

绘制流动折线

折线的颜色可以配置为纯颜色,也可以配置为头尾点的渐变颜色

ts 复制代码
 //折线样式
      ctx.lineCap = props.lineCap || 'butt';
      ctx.shadowBlur = 0;
      ctx.globalAlpha = props.opacity || 1;
      ctx.lineWidth = props.strokeWidth || 1;
      let lineColor: any = 'red';
      if (typeof props.strokeColor === 'string' && props.strokeColor) {
        lineColor = props.strokeColor;
      } else if (Array.isArray(props.strokeColor) && props.strokeColor.length === 2) {
        const start = props.path[0];
        const end = props.path[props.path.length - 1];
        const startColor = props.strokeColor[0];
        const endColor = props.strokeColor[1];
        const grd = ctx.createLinearGradient(start[0], start[1], end[0], end[1]);
        grd.addColorStop(0.1, startColor);
        grd.addColorStop(0.9, endColor);
        lineColor = grd;
      }
      ctx.strokeStyle = lineColor;

折线由实线和间隔部分组成 ,绘制实线段的部分:

  1. 当前实线段的位置:开始位置=移动距离+开始距离结束位置=开始位置+实线长度,为了让折线循环流动需要%折线总长度
  2. 根据实线段开始结束位置判断所在的折线的直线线段,线性取值计算落在该直线线段的点
ts 复制代码
const lerp = (s: number, e: number, t: number) => {
  if (t > 1) return e;
  if (t < 0) return s;
  return s + t * (e - s);
};
const lerpPoint = (start: [number, number], end: [number, number], t: number) => {
  return [lerp(start[0], end[0], t), lerp(start[1], end[1], t)];
};

实线段存在三种情况:

  • 当开始位置小于等于结束位置时,存在两种情况:

    • 实线段在一条直线上。
    • 实线段在拐点,即实线前一段在前一条直线,后一段在后一条直线。
  • 当开始位置大于结束位置时,实线段头尾循环,即实线前一段在最后的一条直线,后一段在开始的一条直线。

ts 复制代码
 //移动距离
      const d = obj.t * sum;
      
      ctx.beginPath();
      for (let i = 0; i < num; i++) {
        //实线开始点距离
        const a = (d + i * unit) % sum;
        for (let j = 0; j < pathList.length; j++) {
          const current = pathList[j];
          //实线结束点距离
          const b = (a + props.stepWidth) % sum;
          if (a >= current.startV && a < current.endV) {
            const t = (a - current.startV) / current.size;
            const p0 = lerpPoint(current.start, current.end, t);
            if (b > a) {
              if (b >= current.startV && b < current.endV) {
                //同一段直线
                const e = (b - current.startV) / current.size;
                const p1 = lerpPoint(current.start, current.end, e);
                ctx.moveTo(p0[0], p0[1]);
                ctx.lineTo(p1[0], p1[1]);
              } else {
                //拐点
                const next = pathList[j + 1];
                ctx.moveTo(p0[0], p0[1]);
                ctx.lineTo(current.end[0], current.end[1]);
                //下一条直线的开始
                const e = (b - next.startV) / next.size;
                const p1 = lerpPoint(next.start, next.end, e);
                ctx.lineTo(p1[0], p1[1]);
              }
            } else {
              //头尾循环
              ctx.moveTo(p0[0], p0[1]);
              ctx.lineTo(current.end[0], current.end[1]);

              //开始直线
              const first = pathList[0];
              const e = (b - first.startV) / first.size;
              const p1 = lerpPoint(first.start, first.end, e);
              ctx.moveTo(first.start[0], first.start[1]);
              ctx.lineTo(p1[0], p1[1]);
            }
            break;
          }
        }
      }
      ctx.stroke();

循环动画开始

  • 当t从0到1则按照路径顺着流动,当t从1到0则按照路径逆着流动,props.reverse配置是否反向流动
  • 动画时间=折线总长度/流动速度,即Math.round((sum / props.speed) * 1000),流动速度props.speed:像素/秒
ts 复制代码
this.theTween = new TWEEN.Tween({ t: props.reverse ? 1 : 0 })
      .to({ t: props.reverse ? 0 : 1 }, Math.round((sum / props.speed) * 1000))
      .repeat(Infinity)
      .onUpdate(drawLine);

    TWEEN.add(this.theTween);
    this.theTween.start();

循环动画结束

ts 复制代码
 stop() {
    if (this.theTween) {
      this.theTween.stop();
      TWEEN.remove(this.theTween);
    }
  }

添加反向流动折线路径

ts 复制代码
const path1 = new RunningPath({
  id: 'path1',
  path: [
    [100, 100],
    [100, 700],
    [700, 100],
    [700, 700]
  ],
  speed: 100,
  strokeWidth: 5,
  strokeColor: ['yellow', 'red'],
  stepWidth: 40,
  dashWidth: 20,
  opacity: 1,
  reverse: true
});

2. 流动小球折线路径

流动小球折线配置

ts 复制代码
type CirclePathType = {
  id: string | number;
  //路径
  path: [number, number][];
  //路径颜色
  pathColor?: string;
  //路径宽度
  pathWidth?: number;
  //路径透明度
  pathOpacity?: number;
  //小球半径
  radius: number;
  //小球颜色
  color?: string;
  //泛光边缘
  blur?: number;
  //透明度
  opacity?: number;
  //反向流动
  reverse?: boolean;
  //小球数量
  pointNum?: number;
  //流动速度 像素/秒
  speed: number;
};

整理路径点信息跟上面一样,但数量是指定的,长度不是实线和虚线组成,而是直接将折线总长度平分

ts 复制代码
//小球数量
    const num = props.pointNum || 5;
    //单位长度
    const unit = sum / num;

绘制路径底线

ts 复制代码
      if (props.pathWidth) {
        ctx.shadowBlur = 0;
        ctx.globalAlpha = props.pathOpacity || 0.3;
        ctx.lineWidth = props.pathWidth;
        ctx.strokeStyle = props.pathColor || 'red';
        ctx.beginPath();
        const startPoint = props.path[0];
        ctx.moveTo(startPoint[0], startPoint[1]);
        for (let i = 1; i < props.path.length; i++) {
          const item = props.path[i];
          ctx.lineTo(item[0], item[1]);
        }
        ctx.stroke();
      }

小球样式

ts 复制代码
  ctx.shadowBlur = props.blur || 10;
      ctx.shadowColor = props.color || 'red';
      ctx.fillStyle = props.color || 'red';
      ctx.globalAlpha = props.opacity || 1;

绘制运动小球

  • 小球的距离=移动距离+小球间隔距离*索引,为了循环运动需要%折线总长度

  • 小球在当前线段的占比=(小球距离-当前线段开始的距离)/当前线段长度,根据当前线段的开始点后结束点,通过线性取值获取小球的具体位置。

ts 复制代码
 //移动距离
      const d = obj.t * sum;
      for (let i = 0; i < num; i++) {
        //小球距离
        const s = (d + i * unit) % sum;
        for (let j = 0; j < pathList.length; j++) {
          const current = pathList[j];
          //球落在该线段
          if (s >= current.startV && s < current.endV) {
            //小球的位置
            const p0 = lerpPoint(current.start, current.end, (s - current.startV) / current.size);
            ctx.beginPath();
            ctx.arc(p0[0], p0[1], props.radius, 0, 2 * Math.PI);
            ctx.fill();
            break;
          }
        }
      }

添加流动小球折线

ts 复制代码
const path2 = new CirclePath({
  id: 'path2',
  path: [
    [200, 100],
    [400, 600],
    [600, 100]
  ],
  pathWidth: 5,
  pointNum: 8,
  color: 'blue',
  pathColor: 'dodgerblue',
  pathOpacity: 0.5,
  speed: 100,
  radius: 10,
  opacity: 1,
  reverse: false
});

3. 流动小球贝塞尔曲线

流动小球贝塞尔曲线配置

ts 复制代码
type CurvePathType = {
  id: string | number;
  //路径
  path: [number, number][];
  //路径颜色
  pathColor?: string;
  //路径宽度
  pathWidth?: number;
  //路径透明度
  pathOpacity?: number;
  //小球数量
  pointNum?: number;
  //小球半径
  radius: number;
  //小球颜色
  color?: string;
  //泛光边缘
  blur?: number;
  //透明度
  opacity?: number;
  //反向流动
  reverse?: boolean;
  //流动速度
  speed: number;
};

计算四个点的距离总和,用于后面流动速度计算

ts 复制代码
//距离总和
   let sum = 0;
    for (let i = 1; i < props.path.length; i++) {
      sum += getDistance(props.path[i - 1], props.path[i]);
    }
    
    //小球数量
    const num = props.pointNum || 5;
    //单位间隔百分比
    const unit = 1 / num;

三阶贝塞尔曲线需要四个点,一个起始点P0,两个控制点P1,P2,一个结束点P3

公式:(1-t)^3*P0+3*(1-t)^2*t*P1+3*(1-t)*t^3*P2+t^3*P3,t的范围[0,1]

那么我们可以得到计算贝塞尔曲线方法

ts 复制代码
   const bezierCurve = (t: number) => {
      const start = props.path[0],
        control1 = props.path[1],
        control2 = props.path[2],
        end = props.path[3];
      const x =
        Math.pow(1 - t, 3) * start[0] +
        3 * t * Math.pow(1 - t, 2) * control1[0] +
        3 * Math.pow(t, 2) * (1 - t) * control2[0] +
        Math.pow(t, 3) * end[0];
      const y =
        Math.pow(1 - t, 3) * start[1] +
        3 * t * Math.pow(1 - t, 2) * control1[1] +
        3 * Math.pow(t, 2) * (1 - t) * control2[1] +
        Math.pow(t, 3) * end[1];
      return [x, y];
    };

绘制贝塞尔曲线路径底线

ts 复制代码
      if (props.pathWidth) {
      //路径样式
        ctx.shadowBlur = 0;
        ctx.globalAlpha = props.pathOpacity || 0.3;
        ctx.lineWidth = props.pathWidth || props.radius * 2;
        ctx.strokeStyle = props.pathColor || 'red';
        //贝塞尔曲线
        ctx.beginPath();
        const startPoint = props.path[0];
        ctx.moveTo(startPoint[0], startPoint[1]);
        const c1 = props.path[1];
        const c2 = props.path[2];
        const endPoint = props.path[3];
        ctx.bezierCurveTo(c1[0], c1[1], c2[0], c2[1], endPoint[0], endPoint[1]);
        ctx.stroke();
      }

绘制贝塞尔曲线上的小球

ts 复制代码
for (let i = 0; i < num; i++) {
        //小球位置百分比
        const s = (obj.t + i * unit) % 1;
        //小球在贝塞尔曲线的位置
        const p0 = bezierCurve(s);
        ctx.beginPath();
        ctx.arc(p0[0], p0[1], props.radius, 0, 2 * Math.PI);
        ctx.fill();
      }

添加流动小球贝塞尔曲线

ts 复制代码
const path3 = new CurvePath({
  id: 'path3',
  path: [
    [500, 50],
    [500, 500],
    [200, 200],

    [100, 600]
  ],
  pathWidth: 5,
  pointNum: 8,
  color: 'red',
  pathColor: 'pink',
  speed: 100,
  radius: 10,
  opacity: 1,
  reverse: false
});

5. 编辑路径

添加编辑点,编辑点存储点的索引信息

ts 复制代码
addPoint(a: [number, number], i: number) {
    const p = document.createElement('div');
    p.className = 'edit-point';
    p.dataset.index = i + '';
    p.dataset.id = this.id + '';
    p.style.left = a[0] + 'px';
    p.style.top = a[1] + 'px';
    p.innerHTML = this.pointText[i] || '';

    return p;
  }

开启编辑

ts 复制代码
  edit(mask: HTMLDivElement) {
    const points: HTMLDivElement[] = [];
    this.props.path.forEach((a, i) => {
      const p = this.addPoint(this.props.path[i], i);
      mask.appendChild(p);
      points.push(p);
    });

    this.points = points;
  }

关闭编辑

ts 复制代码
  unedit(mask: HTMLDivElement) {
    this.points.forEach((a) => {
      mask.removeChild(a);
    });
    this.points = [];
  }

移动点位置,并更新路径

ts 复制代码
const postion = {
      x: 0,
      y: 0
    };
    interact('.edit-point').draggable({
      listeners: {
        start: () => {
          postion.x = 0;
          postion.y = 0;
        },
        move: (ev) => {
          postion.x += ev.dx;
          postion.y += ev.dy;
          const target = ev.target as HTMLElement;
          target.style.transform = `translate(${postion.x}px,${postion.y}px)`;
        },
        end: (ev) => {
          const target = ev.target as HTMLElement;
          target.style.transform = '';
          const idx = Number(target.dataset.index);
          const item = shape.props.path[idx];
          const newItem = [item[0] + postion.x, item[1] + postion.y];
          shape.props.path[idx] = newItem;
          target.style.left = newItem[0] + 'px';
          target.style.top = newItem[1] + 'px';
          shape.draw(this.ctx);
        }
      }
    });

单击画布添加点,并更新路径,添加编辑点

ts 复制代码
 clickAction(ev: MouseEvent, mask: HTMLElement, ctx: CanvasRenderingContext2D) {  
    if (ev.target === mask && this.props.path.length < 4) {
      const item: [number, number] = [Math.round(ev.offsetX), Math.round(ev.offsetY)];
      const p = this.addPoint(item, this.props.path.length);
      this.props.path.push(item);
      mask.appendChild(p);
      this.points.push(p);
      this.draw(ctx);
    }
  }

双击某个点删除,并更新路径,修改编辑点信息

ts 复制代码
dbclickAction(ev: MouseEvent, mask: HTMLElement, ctx: CanvasRenderingContext2D) {
    const target = ev.target as HTMLElement;   
    if (target.dataset.id && target.dataset.index) {
      const idx = Number(target.dataset.index);
      this.props.path.splice(idx, 1);
      this.points.splice(idx, 1);
      mask.removeChild(target);
      this.points.forEach((a, i) => {
        a.innerHTML = this.pointText[i];
        a.dataset.index = i + '';
      });
      this.draw(ctx);
    }
  }

6. 封装成Vue3 canvas绘制组件

CanvasManager父级Canvas组件,控制动画TWEEN更新

html 复制代码
<template>
  <canvas :height="height" :width="width" ref="canvasRef"> </canvas>
  <slot></slot>
</template>

<script setup lang="ts">
  import TWEEN from "@tweenjs/tween.js";
  import {useTemplateRef, onMounted, onBeforeUnmount, provide} from "vue";
  const canvasRef = useTemplateRef<HTMLCanvasElement>("canvasRef");
  provide("canvasRef", canvasRef);
  const props = withDefaults(defineProps<{height: number; width: number}>(), {});
  let animate: any;
  //开始动画
  const onAniamte = () => {
    const canvas = canvasRef.value;
    if (canvas) {
      const ctx = canvas.getContext("2d");
      ctx?.clearRect(0, 0, canvas.width, canvas.height);
    }
    TWEEN.update();
    animate = requestAnimationFrame(onAniamte);
  };
  onMounted(() => {
    onAniamte();
  });
  //取消动画
  onBeforeUnmount(() => {
    if (animate) cancelAnimationFrame(animate);
  });
</script>

useCanvasDraw绘制hook,监听props属性更新绘制

ts 复制代码
import {onBeforeUnmount, onMounted, ref, watch} from "vue";
import * as TWEEN from "@tweenjs/tween.js";
import {debounce} from "lodash-es";
export const useCanvasDraw = (props: any, drawFun: () => void) => {
  const theTween = ref<any>();
  //开始绘制
  const startDraw = debounce(drawFun, 100);
  watch(
    () => props,
    () => {
      startDraw();
    },
    {
      deep: true
    }
  );
  //停止绘制
  const stopDraw = () => {
    if (theTween.value) {
      theTween.value.stop();
      TWEEN.remove(theTween.value);
    }
  };
  onMounted(() => {
    startDraw();
  });

  onBeforeUnmount(() => {
    stopDraw();
  });
  return {theTween, startDraw, stopDraw};
};

vue3路径绘制组件,从父级获取canvas绘制路径

html 复制代码
<template>
  <div class="canvas-draw"></div>
</template>

<script setup lang="ts">
  import {inject} from "vue";
  import * as TWEEN from "@tweenjs/tween.js";
  import {useCanvasDraw} from "./useCanvasDraw";
  //从父级获取canvas
  const canvasRef = inject<any>("canvasRef");
  const props = withDefaults(
    defineProps<{
      path: [number, number][];
       //...
    }>(),
    { }
  );
  
  //绘制路径
  const drawPath = () => {
    stopDraw();     
    if (canvasRef?.value && props.path.length >= 2) {
    //...
   }
  };
  const {theTween, stopDraw} = useCanvasDraw(props, drawPath);
</script>

<style lang="scss" scoped>
  .canvas-draw {
    display: none;
  }
</style>
html 复制代码
<script setup lang="ts">
  import CanvasManager from "./CanvasManager.vue";
  import RunningPath from "./shapes/RunningPath.vue";
  import CirclePath from "./shapes/CirclePath.vue";
  import CurvePath from "./shapes/CurvePath.vue";

  const path1: [number, number][] = [
    [100, 100],
    [100, 700],
    [700, 100],
    [700, 700]
  ];
  const path2: [number, number][] = [
    [200, 100],
    [400, 600],
    [600, 100]
  ];
  const path3: [number, number][] = [
    [500, 50],
    [500, 500],
    [200, 200],
    [100, 600]
  ];
</script>

<template>
  <CanvasManager :width="800" :height="800">
    <RunningPath :path="path1" :stroke-color="['yellow', 'red']" :reverse="true"></RunningPath>
    <CirclePath :path="path2"></CirclePath>
    <CurvePath :path="path3"></CurvePath>
  </CanvasManager>
</template>

7.Github地址

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

  • https://github.com/xiaolidan00/vue3-canvas-path

相关推荐
盛夏绽放3 小时前
uni-app Vue 项目的规范目录结构全解
前端·vue.js·uni-app
国家不保护废物4 小时前
Vue组件通信全攻略:从父子传到事件总线,玩转组件数据流!
前端·vue.js
写不来代码的草莓熊4 小时前
vue前端面试题——记录一次面试当中遇到的题(9)
前端·javascript·vue.js
二十雨辰5 小时前
eduAi-智能体创意平台
前端·vue.js
m0dw5 小时前
vue懒加载
前端·javascript·vue.js·typescript
国家不保护废物6 小时前
手写 Vue Router,揭秘路由背后的魔法!🔮
前端·vue.js
小光学长6 小时前
基于Vue的保护动物信息管理系统r7zl6b88 (程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
前端·数据库·vue.js
麦麦大数据8 小时前
F029 vue游戏推荐大数据可视化系统vue+flask+mysql|steam游戏平台可视化
vue.js·游戏·信息可视化·flask·推荐算法·游戏推荐
paopaokaka_luck9 小时前
基于SpringBoot+Vue的社区诊所管理系统(AI问答、webSocket实时聊天、Echarts图形化分析)
vue.js·人工智能·spring boot·后端·websocket