本文会带大家使用 TypeScript 继续封装构造函数 CanvasContainer。以实现绘制直线、矩形、多边形的功能,并处理绘制海量图形带来的卡顿问题。
二、Canvas 画布:绘制直线、矩形、多边形 👉👉👉 代码演示
三、Canvas 画布:图形的选中和移动 (上) 👉👉👉 代码演示
四、Canvas 画布:图形的选中和移动 (下) 👉👉👉 代码演示
五、Canvas 画布:修改图形各端点的位置 👉👉👉 代码演示
六、Canvas 画布:图形移动时的辅助线和吸附效果 👉👉👉 代码演示
一、确定图形的数据结构
第一步:首先我们需要再创建几个绘制图形的相关变量。
type GraphType = 'line' | 'rect' | 'polygon';
class CanvasContainer {
......
/** 是否绘制图形 */
public isDrawGraph = false;
/** 正在绘制的图形的类型 */
public drawingGraphType?: GraphType = undefined;
/** 画布上已经绘制好的图形 */
public graphs: GraphInter[] = [];
/** 是否正在绘制多边形 */
private isDrawingPolygon = false;
}
第二步:确认存放入 graphs 的图形:直线、矩形、多边形的数据结构。
// 一个数组,存放图形的每一个端点,x 表示端点的 X 轴坐标;y 表示端点的 Y 轴坐标。
type PointsType = { x: number; y: number }[];
// 用对象来存储图形的数据,type 表示图形的类型;points 表示图形的每一个端点。
interface GraphInter {
id: number;
type: GraphType;
points: PointsType;
}
数据的示例:
// 直线的数据结构
{
id: Math.random(),
type: 'line',
points: [
{ x: 直线开始点的 X 坐标, y: 直线开始点的 Y 坐标 },
{ x: 直线结束点的 X 坐标, y: 直线结束点的 Y 坐标 },
],
}
// 矩形的数据结构
{
id: Math.random(),
type: 'rect',
points: [
{ x: 矩形左上角的 X 坐标, y: 矩形左上角的 Y 坐标 },
{ x: 矩形右下角的 X 坐标, y: 矩形右下角的 Y 坐标 },
],
}
// 多边形的数据结构
{
id: Math.random(),
type: 'polygon',
points: [
{ x: 多边形端点的 X 坐标, y: 多边形端点的 Y 坐标 },
......
],
}
二、绘制图形
根据我们定义的数据结构可以看出,绘制图形的时候,我们只需要记录绘制图形所需要的几个端点就可以了。
- 对
直线
来说,绘制图形时,鼠标按下
的位置就是直线开始点
的位置,鼠标抬起
的位置就是直线结束点
的位置。 - 对
矩形
来说,绘制图形时,鼠标按下
的位置就是矩形左上角
的位置,鼠标抬起
的位置就是矩形右下角
的位置。 - 对
多边形
来说,绘制图形时,鼠标按下
的位置是图形每个端点
的位置,鼠标双击
的位置就是图形结束绘制
的位置。
1. 绘制直线、矩形
- 在绘制
直线
和矩形
时,我们将鼠标按下的位置
作为 直线 的开始点
或 矩形 的左上角
的位置,以此开始绘制。因此需要在鼠标按下事件中添加一下代码。this.graphs.push({ id: Math.random(), type: this.drawingGraphType, // 图形的类型: line 或 rect points: [ { // (-画布的偏移量 + 鼠标的按下位置) / 画布的缩放比例 x: (-this.offsetX + event.x) / this.scale, y: (-this.offsetY + event.y) / this.scale, }, ], });
- 在鼠标按下后并开始移动时,
鼠标移动到的位置
就是直线的结束点
或矩形的右下角
,也就是图形绘制结束的位置。因此需要在鼠标移动事件中添加一下代码。// 图形列表的最后一个图形,就是正在绘制的图形。 const graph = this.graphs.at(-1) as GraphInter; // 保存鼠标移动时的位置为 直线的结束点 或 矩形的右下角的点。 graph.points[1] = { x: (-this.offsetX + event.x) / this.scale, y: (-this.offsetY + event.y) / this.scale, };
2. 绘制多边形
-
在绘制多边形时,鼠标每次点击都会给多边形添加一个端点。当新增的是第一个端点时,我们需要创建一个多边形的数据结构,并将当前点设置为多边形的第一个点和最后一个点;当新增的不是第一个端点时,我们需要将当前点添加到当前的多边形中。
// 判断当前按下时是不是多边形的第一个点 if (!this.isDrawingPolygon) { // 如果是多边形的第一个点,则创建一个多边形图形,并将按下的位置设置为多边形的第一个端点和最后一个端点。 this.graphs.push({ id: Math.random(), type: this.drawingGraphType, points: [ { x: (-this.offsetX + event.x) / this.scale, y: (-this.offsetY + event.y) / this.scale, }, { x: (-this.offsetX + event.x) / this.scale, y: (-this.offsetY + event.y) / this.scale, }, ], }) // 将表示正在绘制多边形的变量设为 true。 this.isDrawingPolygon = true; } // 如果不是多边形的第一个点,则获取到图形列表中的最后一个,并给其新增一个端点。 else { // 当前正在绘制的多变形的序号 const index = this.graphs.length - 1; // 给当前重在绘制的多边形多添加一个端点 this.graphs[index].points.push({ x: (-this.offsetX + event.x) / this.scale, y: (-this.offsetY + event.y) / this.scale, }) }
-
在绘制多边形的过程中,鼠标在画布上的位置就是多边形下一个端点的位置。因此我们要暂时的记录这个位置为多边形的最后一个端点的位置,以便于更好的看到多边形的样子。
// 当前正在绘制的多变形的序号 const index = graph.points.length - 1; // 暂时将鼠标的位置记录为多边形的最后一个点 graph.points[index] = { x: (-this.offsetX + event.x) / this.scale, y: (-this.offsetY + event.y) / this.scale, };
-
在我们双击鼠标时,结束多边形的绘制。此时,如果多边形的端点数小于 3 个,则当前多边形绘制失败。
this.isDrawingPolygon = false; // 获取多边形的端点数量。 const index = this.graphs.at(-1)?.points?.length || 0; // 因为 一次双击事件 同时触发 两次单击事件 ,所以双击结束多边形绘制时,会多出两个端点。 if (index < 5) { // 此多边形绘制失败,删除掉。 this.graphs.splice(this.graphs.length - 1, 1); } else { // 删除掉最后两个多出来的端点。 this.graphs.at(-1)?.points.splice(index - 2, 2); }
三、图形超出画布可视区的处理
当我们在画布上绘制了五、六万个图形,甚至更多时,渲染所有的图形所用时长会远超浏览器渲染一帧的时长,此时画布的拖拽会变得有些卡顿。为了解决这个问题,我们要对渲染做出优化。
Canvas 画布的可视区的大小是有限的,因为画布的 拖拽
和 缩放
,使得画布上的一些图形会被从可视区移出去,此时再渲染这些图形就显得没有必要了,我们就要省略掉绘制这一部分图形的消耗,来优化渲染性能。
1. 准备工具函数
要判断图形是否在画布的可视区内,我们就要知道图形的每一个点都在不在画布的可视区内。此时,我们就需要一个函数来帮助我们判断一个点是否在一个多边形内。
// 点是否在多边形内
isPointInGraph = (p: { x: number; y: number }, poly: PointsType) => {
// px,py为p点的x和y坐标
let px = p.x,
py = p.y,
flag = false;
//这个for循环是为了遍历多边形的每一个线段
for(let i = 0, l = poly.length, j = l - 1; i < l; j = i, i++) {
let sx = poly[i].x, //线段起点x坐标
sy = poly[i].y, //线段起点y坐标
tx = poly[j].x, //线段终点x坐标
ty = poly[j].y //线段终点y坐标
// 点与多边形顶点重合
if((sx === px && sy === py) || (tx === px && ty === py)) {
return true
}
// 点的射线和多边形的一条边重合,并且点在边上
if((sy === ty && sy === py) && ((sx > px && tx < px) || (sx < px && tx > px))) {
return true
}
// 判断线段两端点是否在射线两侧
if((sy < py && ty >= py) || (sy >= py && ty < py)) {
// 求射线和线段的交点x坐标,交点y坐标当然是py
let x = sx + (py - sy) * (tx - sx) / (ty - sy)
// 点在多边形的边上
if(x === px) {
return true
}
// x大于px来保证射线是朝右的,往一个方向射,假如射线穿过多边形的边界,flag取反一下
if(x > px) {
flag = !flag
}
}
}
// 射线穿过多边形边界的次数为奇数时点在多边形内
return flag ? true : false
}
2. 准备测试数据
我们要模拟数万图形同时渲染时,画布的卡顿现象。所以我们的画布上就要有很多图形,现在我们来准备 90000 个矩形来模拟测试,60000 个在画布可视区,30000 个不在画布可视区。
const el = document.getElementById('canvas1') as HTMLCanvasElement;
canvas = new CanvasContainer(el);
for (let i = 0; i < 30000; i++) {
canvas.graphs.push({
id: Math.random(),
type: 'rect',
points: [
{ x: -1, y: -1 },
{ x: -Math.random() * 400, y: -Math.random() * 400 },
]
})
canvas.graphs.push({
id: Math.random(),
type: 'rect',
points: [
{ x: 400, y: 400 },
{ x: Math.random() * 100 + 400, y: Math.random() * 100 + 400 },
]
})
canvas.graphs.push({
id: Math.random(),
type: 'rect',
points: [
{ x: 900, y: 400 },
{ x: Math.random() * 100 + 900, y: Math.random() * 100 + 400 },
]
})
}
canvas.render();
3. 处理超出边界的渲染问题
1. 计算画布可视区的位置
我们要通过只渲染画布可视区的图形的优化性能,那么我们首先就要知道在画布平移和缩放之后,画布的可视区在什么位置。
const rect = [
{
x: -this.offsetX / this.scale,
y: -this.offsetY / this.scale,
},
{
x: (-this.offsetX + this.width) / this.scale,
y: (-this.offsetY + this.height) / this.scale,
}
];
2. 统一图形的数据结构
因为我们图形的 数据结构 和 判断点在多边形内函数 的参数不一致。所以我们还需要一个函数来统一它们的数据结构。
normalizationPoint = (type: GraphType, points: PointsType) => {
let p: PointsType = [];
switch(type) {
case 'rect':
p = [
{ ...points[0] },
{ x: points[1]?.x, y: points[0].y },
{ ...points[1] },
{ x: points[0].x, y: points[1]?.y },
];
break;
case 'line':
p = [
{ x: points[0].x - 10, y: points[0].y - 10 },
{ x: points[1]?.x + 10, y: points[1]?.y },
{ x: points[1]?.x, y: points[1]?.y + 10 },
{ x: points[0].x, y: points[0].y + 10 },
];
break;
case 'polygon':
p = points;
break;
}
return p;
}
3. 判断是否在可视区内
我们已经知道的画布可视区的位置。所以现在,我们只需要判断图形上的点是不是在画布的可视区内,来判断要不要渲染图形。
规则:如果图形内有一个点在画布的可视区内,我们就认为此图形需要渲染。如果图形内的所有点都不在画布的可视区内,我们就认为此图形不需要渲染。
const points = this.normalizationPoint('rect', rect);
let isShow = false;
// 判断图形上是否有端点在可视区
for (let i = 0; i < graph.points.length; i++) {
if (this.isPointInGraph(graph.points[i], points)) {
isShow = true;
// 如果有点在可视区内,则表示此图形需要渲染,可以停止当前循环
break;
}
}
// 如果都没有,在不渲染
if (!isShow) return;