Canvas 画布:绘制直线、矩形、多边形

591 阅读9分钟

本文会带大家使用 TypeScript 继续封装构造函数 CanvasContainer。以实现绘制直线、矩形、多边形的功能,并处理绘制海量图形带来的卡顿问题。

一、Canvas 画布:拖动和缩放 👉👉👉 代码演示

二、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;

结束