在Canvas中用鼠标绘制多边形区域

发布时间 2023-08-22 16:36:35作者: sanhuamao

一 需求场景

需要在监控画面中绘制一块监测区域(多边形),最后取多边形的各个坐标点。

  1. 鼠标左键:加入路径点
  2. 鼠标右键:撤销
  3. 鼠标移动:动态绘制区域
  4. 双击鼠标左键:清除画布
  5. 回车:完成绘图

二 效果图

三 Canvas基本概念

如果你对canvas不熟悉,不妨简单过一下概念,以便理解代码。

1. 坐标系

2. 创建canvas与初始化画布

<canvas
   id="myCanvas"
   style="background-color: yellow"
   width="400"
   height="400"
   tabindex="0"
></canvas>

<script>
// 获取元素
const el=document.getElementById('myCanvas')
// 创建画布
const ctx=el.getContext('2d')
</script>

3. 绘制直线路径

ctx.beginPath() // 路径开始
ctx.moveTo(100,100) // 鼠标的起点位置
ctx.lineTo(100,200) // 从上个点出发,连线至(100,200)
ctx.stroke() // 描线结束

4. 设置样式

ctx.beginPath() 
ctx.rect(100,100,200,200) // 绘制矩形
ctx.lineWidth=7  // 边框宽度
ctx.strokeStyle='blue' // 边框颜色
ctx.fillStyle='red' // 填充背景色,需要注意前后顺序
ctx.fill()  // 填充
ctx.stroke() // 描线

四 事件属性

绘制区域的过程中,用到了鼠标点击事件等。我们需要鼠标的位置信息,那么就引出了以下容易混淆的概念,简单列一下:

el.addEventListener('click', (e)=>{
	e.clientX // 鼠标相对于客户端窗口的水平坐标位置
	e.offsetX // 鼠标与目标节点的边在X轴的偏移量
	e.x //`clientX`的别名
	e.movementX // 两个事件间水平移动的距离
	e.pageX // 相对于整个文档的 x坐标值(像素)
	e.screenX // 鼠标在屏幕的水平坐标
});

这里需要的属性是:e.offsetX, e.offsetY

五 实现步骤

1. 初始化画布

<canvas
   id="myCanvas"
   style="background-color: yellow"
   width="400"
   height="400"
   tabindex="0"
></canvas>


<script>
// canvas标签
const el = document.getElementById('myCanvas');
const w = el.width;
const h = el.height;

// 初始化画布
const ctx = el.getContext('2d');
ctx.lineWidth = 4;
ctx.strokeStyle = '#0073e6';
ctx.fillStyle = 'rgba(0, 115, 230,0.2)';

const points = []; // 路径点位
let isPaint = true; // 是否处于绘制状态,完成绘制时会用到

</script>

2. 加入路径点位

通过click事件加入坐标点:

const onClick = (e) => {
    points.push({ x: e.offsetX, y: e.offsetY });
};
el.addEventListener('click', onClick);

3. 实时绘制

当加入了第一个点位之后,就要根据鼠标移动的位置实现动态绘制,这里用到了mousemove事件。

(1) 当points中有一个点时,它将与鼠标移动的位置形成一条直线

(2) 当points中有两个及以上的点时,这些点将与鼠标移动的位置形成一个区域

const onMouseMove = (e) => {
    draw({ x: e.offsetX, y: e.offsetY });
};
el.addEventListener('mousemove', onMouseMove);

根据坐标点数量情况采取不同的绘制方式:

const draw = (mouse) => {
    const pointLength = points.length;
    if (isPaint === false) return;  // 非绘制状态下不用绘制
    switch (pointLength) {
        case 0:
	        return;  // 没有点,不用绘制
        case 1:
	        drawLine(mouse); // 画直线
	        break;
        default:
	        drawArea(mouse); // 画区域
	        break;
    }
};

// 绘制直线
const drawLine = (mouse) => {
    ctx.clearRect(0, 0, w, h);  // 每次绘制前都要清空一下画布
    
    const firstPoint = points[0];
    ctx.beginPath();
    ctx.moveTo(firstPoint.x, firstPoint.y);
    ctx.lineTo(mouse.x, mouse.y);
    ctx.stroke();
};

// 绘制区域
const drawArea = (mouse) => {
    ctx.clearRect(0, 0, w, h);
    
    ctx.beginPath();
    for (let i = 0; i < points.length; i++) {
        if (i === 0) {
	        ctx.moveTo(points[i].x, points[i].y); // 第一个点用的moveTo
        } else {
	        ctx.lineTo(points[i].x, points[i].y); // 其余点用lineTo
        }
    }
    
    // 如果传入了鼠标点,将它作为一个临时点,作为points中的最后一个点;
    // 否则只需将point数组中的点连接即可
    if (mouse) {
        ctx.lineTo(mouse.x, mouse.y);
    }

    // 画回第一个点,封闭区域
    ctx.lineTo(points[0].x, points[0].y);

    // 划线与填充
    ctx.stroke();
    ctx.fill();
};

4. 撤销点位

监听鼠标右键,这里需要注意阻止默认事件

el.addEventListener('contextmenu', onRightClick);

const onRightClick = (e) => {
	// 防止打开菜单菜单
    e.stopPropagation();
    e.preventDefault(); 

	// 如果没有点 不用撤销
    if(points.length===0) return  
    
    points.pop() // 移除最后一个点 
    // 移除点后需要重新绘制
    draw({ x: e.offsetX, y: e.offsetY })
    return false
};

5. 回车完成绘制

 el.addEventListener('keydown', onEnter);
 
const onEnter = (e) => {
   let keyCode = e.keyCode || e.which || e.charCode;
   if (keyCode == 13) {
      const pointLength = points.length;
      // 至少需要三个点才允许画区域
      if (pointLength >= 3) {
         isPaint = false; // 停止绘制
         drawArea(); // 画封闭区域
      }
   }
};

需要注意的是,这里要把canvas标签的tabindex设为0,否则回车将不会生效。(tabindex用来定位html元素,即使用tab键时焦点的顺序)

6. 清空画布

el.addEventListener('dblclick', onClear);

const onClear = () => {
	// 清空画布意味着要重新开始绘制了,所以将绘制状态打开,并清空路径点位
    if (isPaint === false) {
        isPaint = true;
    }
    points = [];
    ctx.clearRect(0, 0, w, h);
};

源码与封装

目前为止,基本功能已经实现了。完整的代码可以点击链接获取:
https://github.com/sanhuamao1/useDraw_polygon

那么如何将它用在实际场景中呢?我在里面简单封装了useDraw,使用方式如下,只需要传入Dom元素(将会创建一个等宽等高的canvas覆在Dom元素上方)和一些选项即可,其他的自行扩展啦~

(对usehook不熟,瞎琢磨,写的不好欢迎指出!)

import useDraw from './useDraw';

let draw=useDraw()
draw.init(domRef, {
   // 传入一些选项。
   // 绘制完成时的回调
   onComplete: (points) => {
      console.log('points', points);
   }
});

// 用完后记得销毁
draw.destroy()