Canvas还能这么玩?(Canvas实现图片标注)
我的Canvas入门记录,利用Canvas实现图片标注
大致需求
服务端返回图片地址,前端需要加载图片并在其上进行标注区域,然后把点位数组返回给后端。 一图流效果如下:
实现过程
绘制底图
首先需要把后端返回的图片地址,渲染在Canvas当中(这里有2个Canvas标签,一个是绘制底图, 一个是绘制标注图层,需要保证2个Canvas宽高一致,利用CSS使标注图层覆盖在底图之上), 利用React useEffect监听props里的src改变,从而渲染底图。由于不知道图片的大小,为了图片渲染出来比例一样,所以需要根据Canvas宽高与图片宽高来计算缩放比例(用useRef进行了存储,方便后面计算坐标点位)。
useEffect(() => { if(!src) return; const ctx = baseCanvas.current.getContext("2d"); setLoading(true); //图片加载完后,将其绘制在canvas中 const img = new Image(); img.src = src; img.onload = function () { ctx.clearRect(0, 0, width, height); // 清除底图 let _width = img.width, _height = img.height; imgInfo.current = { width: _width, height: _height } // 存储图片宽高 防止计算导致的误差 if (img.width > width || img.height > height) { scale.current = img.width > img.height ? img.width / width : img.height / height; _width = img.width / scale.current; _height = img.height / scale.current; } // 等比例缩放 baseCanvas.current.width = topCanvas.current.width = _width; baseCanvas.current.height = topCanvas.current.height = _height; ctx.drawImage(this, 0, 0, _width, _height); setLoading(false); handleClear(); // 清除标注 } img.onerror = () => { warn_barry('图片加载失败'); setLoading(false); } }, [src]); 复制代码
好了,到这一步,已经差不多完成了对吧。可以摸2天鱼,剩下的周五再说,手动狗头。
描点与连线
定义一个point数组,用来存储每次新增标注的点位信息。 在Canvas上绑定点击事件,点击之后在标注图层进行描点,将该点与point数组里的最后一个点位信息进行连线,再把信息存入point数组。
let points: Array<point> = []; const clickFn = (e: any) => { if (brush !== 'add') return; clearTimeout(timer); timer = setTimeout(() => { let x = e.offsetX, y = e.offsetY; const index = points.findIndex(item => Math.abs(equalScale(item[0], false) - x) < 10 && Math.abs(equalScale(item[1], false) - y) < 10); if (index > 0) { console.error('请连接起点'); return; } if (index === 0) { x = equalScale(points[0][0], false); y = equalScale(points[0][1], false); } const ctx = topCanvas.current.getContext("2d"); ctx.fillStyle = "rgba(24, 144, 255, 1)"; ctx.fillRect(x - 3, y - 3, 6, 6); let prevPoint; if (prevPoint = points[points.length - 1]) { ctx.strokeStyle = "rgba(24, 144, 255, 1)"; ctx.beginPath(); ctx.moveTo(equalScale(prevPoint[0], false), equalScale(prevPoint[1], false)); ctx.lineTo(x, y); ctx.stroke(); ctx.closePath(); } points.push([equalScale(x), equalScale(y)]); }, 200); } // 单击绘制坐标点及连线 topCanvas.current.addEventListener('click', clickFn); // 给标注图层绑定点击事件 复制代码
双击退出新增,绘制标注区域
上面的范围绘制出来之后,提供保存操作(保存之后不允许再次进行描点与连线,只能拖拽节点,改变形状),进行区域绘制。
// 绘制标注方法 const draw = () => { const ctx = topCanvas.current.getContext("2d"); ctx.clearRect(0, 0, width, height); markArr.current.forEach(mark => { // 遍历标注数组,绘制所有的标注区域 const { points } = mark; ctx.beginPath(); ctx.fillStyle = "rgba(24, 144, 255, 1)"; points.forEach((item, index) => { const x = equalScale(item[0], false), y = equalScale(item[1], false); ctx.fillRect(x - 3, y - 3, 6, 6); index === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); ctx.fillStyle = 'rgba(24, 144, 255, .5)'; ctx.fill(); ctx.closePath(); }); } const dbClickFn = () => { clearTimeout(timer); if(points.length < 3) { setBrush(''); return; // 构不成平面则不保存 } if (JSON.stringify(points[0]) !== JSON.stringify(points[points.length - 1])) points.push(points[0]); // 默认连接起点 markArr.current.push({ key: new Date().getTime(), points }); // 把临时点位数组存起来 handleChange(); // 触发props传入的onChange事件 draw(); // 绘制标注区域 setBrush(''); // 退出新增状态 } // 双击保存当前标注 复制代码
节点拖拽改变标注形状
上面的操作已经成功把标注区域的范围绘制了出来,下面需要进行微调区域,需要实现拖拽节点进行重新绘制。 定义了一个current对象,用来存储当前移动点位。
监听鼠标移动事件,改变current里的点位信息,重新绘制点位以及区域
监听鼠标按下事件,判断当前位置是否与标注点位进行重合,如果重合把该点位信息存入current
监听鼠标抬起事件,清空current对象,触发props里的onChange事件,把最终的点位信息传递出去
let current: null | point | undefined = null; const moveFn = throttle((e: any) => { topCanvas.current.style.cursor = 'default'; const x = e.offsetX, y = e.offsetY; if (current) { current[0] = equalScale(x); current[1] = equalScale(y); draw(); } }, 10); const mousedownFn = (e: any) => { const x = equalScale(e.offsetX), y = equalScale(e.offsetY); for (let i = 0; i < markArr.current.length; i++) { const { points } = markArr.current[i]; current = points.find(item => Math.abs(item[0] - x) < 20 && Math.abs(item[1] - y) < 20); if(current) break; } } const mouseupFn = () => { current = null; handleChange(); } topCanvas.current.addEventListener('mousemove', moveFn); topCanvas.current.addEventListener('mousedown', mousedownFn); topCanvas.current.addEventListener('mouseup', mouseupFn); 复制代码
到这里,已经差不多完成了。
json数据双向绑定
现在大致需求已经实现了,但客户又提出了需要可视化json数据,并且能进行更改。
大致意思就是用户不想描点,通过直接改变点位信息来改变形状(很迷惑)。所以就有了右边这一串的json数据,用户可以更改json里面的点位信息来达到绘制区域的效果。由于我把标注组件封装成了一个类似antd Input的效果,它是没有自己的状态的,数据全通过props里的value,onChange来进行更改,唯一要做的就是缓存Canvas元素,避免不必要的渲染,每次新增标注完成和拖拽完成再触发props里的onChange,切忌在拖拽过程中去调用onChange,你可能会发现页面很卡。
作者:hahayq
链接:https://juejin.cn/post/7073308299843600421