整理汇总一下之前用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;
折线由实线和间隔部分组成 ,绘制实线段的部分:
- 当前实线段的位置:
开始位置=移动距离+开始距离
,结束位置=开始位置+实线长度
,为了让折线循环流动需要%折线总长度
- 根据实线段开始结束位置判断所在的折线的直线线段,线性取值计算落在该直线线段的点
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