本文会带大家使用 TypeScript 继续封装构造函数 CanvasContainer。以实现直线、矩形、多边形的 选中
和 移动
功能,并处理 海量图形移动
带来的卡顿问题。
二、Canvas 画布:绘制直线、矩形、多边形 👉👉👉 代码演示
三、Canvas 画布:图形的选中和移动 (上) 👉👉👉 代码演示
四、Canvas 画布:图形的选中和移动 (下) 👉👉👉 代码演示
五、Canvas 画布:修改图形各端点的位置 👉👉👉 代码演示
六、Canvas 画布:图形移动时的辅助线和吸附效果 👉👉👉 代码演示
一、优化图形的数据结构
第一步:首先我们需要对图形的数据结构进行扩展,并定义几个用到的变量。
// 给图形新增两个属性:area 表示图形的面积,select 表示图形是否被选中
interface GraphInter {
id: number;
type: GraphType;
points: PointsType;
area: number;
select: boolean;
}
class CanvasContainer {
/** 绘制没有选中的图形 */
public renderCanvas = null as unknown as HTMLCanvasElement;
/** 画布的上下文 */
public renderCtx = null as unknown as CanvasRenderingContext2D;
......
/** 是否移动图形 */
public isMoveGraph = false;
/** 当前选中的图形 */
public selectGraph?: GraphInter = undefined;
/** 记录鼠标按下时选中图形的位置信息 */
private mouseDownSelectGraphInfo?: GraphInter = undefined;
}
二、图形的选中
1. 判断鼠标是否选中图形
当鼠标在画布上移动的时候,如果鼠标进入到了图形里面,则表示鼠标选中了图形。我们可以把鼠标看成一个点,这时问题就转为了判断一个点是否在图形内。
/**
* 判断当前鼠标按下的位置是否有图形
* @param x 鼠标按下的 X 坐标
* @param y 鼠标按下的 Y 坐标
* @returns 按下位置的图形
*/
pointInGraph = (x: number, y: number): GraphInter => {
let currentGraph: GraphInter = null;
this.graphs.forEach(graph => {
const point = {
x: (-this.offsetX + x) / this.scale,
y: (-this.offsetY + y) / this.scale,
};
// 统一图形的数据格式
const points = this.normalizationPoint(graph.type, graph.points);
// 判断点是否在图形内,当前的会覆盖上一个。
if (this.isPointInGraph(point, points)) {
currentGraph = graph;
}
})
return sortGraph;
}
2. 选中重叠的图形
根据上面的选中方法,当要选中的图形发生重叠时,选中的图形存在问题:选中的图形一直是最后一个图形
。
为了解决这个问题,我们采用的方法是:当图形重叠时,一直选中重叠图形中面积最小的那一个
。
2.1 在绘制图形完成时,记录图形的面积。
// 新增一个工具函数,用于计算面积
getGraphArea = (points: PointsType) => {
let area = 0;
// 将多边形分解为三角形
for (let i = 0; i < points.length; i++) {
let j = (i + 1) % points.length;
area += points[i].x * points[j].y;
area -= points[j].x * points[i].y;
}
area = Math.abs(area) / 2;
return area;
}
// 在 onMouseMoveDraw 函数中添加 线段 和 矩形 的面积
graph.area = this.getGraphArea(
this.normalizationPoint(this.drawingGraphType, graph.points)
);
// 在 onDblclickDraw 函数中添加 多边形 的面积
graph.area = this.getGraphArea(
this.normalizationPoint('polygon', graph?.points)
);
2.2 优化选中函数
pointInGraph = (x: number, y: number): GraphInter => {
let currentGraph: GraphInter[] = [];
this.graphs.forEach(graph => {
const point = {
x: (-this.offsetX + x) / this.scale,
y: (-this.offsetY + y) / this.scale,
};
const points = this.normalizationPoint(graph.type, graph.points);
if (this.isPointInGraph(point, points)) {
// 保存所有的选中图形
currentGraph.push(graph);
}
})
// 给所有选中的图形根据面积大小排序
const sortGraph = currentGraph.sort((pre, next) => {
return pre.area > next.area ? 1 : -1;
})
// 将面积最小的视为选中图形
return sortGraph[0];
}
3. 添加鼠标移动事件
给鼠标添加移动事件,用来判断当前鼠标位置是否有图形被选中。如果有图形被选中,则鼠标的样式由 default
变为 move
;如果没有图形被选中,则鼠标的样式由 move
变为 default
。
/** 鼠标移入或移出图形时的样式 */
private onMouseMove = (event: MouseEvent) => {
if (!this.isMoveGraph) return;
this.log_move && console.time('move')
// 图形是否选中
const selectGraph = this.pointInGraph(event.x, event.y);
this.log_move && console.timeEnd('move')
if (selectGraph) {
this.canvas.style.cursor = 'move';
} else {
this.canvas.style.cursor = 'default';
}
}
鼠标移动事件触发的太频繁,我们给移动事件添加一个防抖函数,让其触发的频率降低,以优化性能。
debounce = (func: Function, wait = 200) => {
let timer: any = null;
return (...args: any[]) => {
if (timer) return;
timer = setTimeout(() => {
func(...args);
clearTimeout(timer);
timer = null;
}, wait);
}
}
this.canvas.addEventListener('mousemove', this.debounce(this.onMouseMove, 100));
三、图形的移动
要使图形移动,首先我们要在鼠标按下的时候判断选中的是那个图形,然后记录这个图形的位置,最后为了方便我们确认选中的是那个图形,我们还要修改图形的颜色,使其更好区分。
1. 准备移动
在图形移动前,我们要做两件事情:
-
选中图形,并记录位置
// 判断图形是否可以移动 if (this.isMoveGraph) { // 取消上次选中的图形的选中状态 this.graphs.forEach(item => item.select = false) // 获取当前的选中图形 this.selectGraph = this.pointInGraph(event.x, event.y); // 判断当前的选中图形是否存在 if (this.selectGraph) { this.selectGraph.select = true; // 记录鼠标按下时,当前选中图形的状态 this.mouseDownSelectGraphInfo = JSON.parse(JSON.stringify(this.selectGraph)); } }
-
修改图形颜色
// 将选中图形的框变为红色 if (graph.select) { ctx.strokeStyle = '#f00'; }
2. 移动图形
在鼠标按下后并开始移动时,鼠标移动到的位置
相对于 鼠标按下时的位置
就是图形所要移动的距离。
if (this.isMoveGraph && this.selectGraph && this.mouseDownSelectGraphInfo) {
// 图形最新的位置 = 图形在鼠标按下时的位置 + ( 鼠标当前的位置 - 鼠标按下时鼠标在画布上的偏移量 ) / 画布的缩放比例
this.selectGraph.points = this.mouseDownSelectGraphInfo?.points.map(item => {
// item.x 鼠标按下时,图形的 X 坐标
// event.x 鼠标当前位置的 X 坐标
// this.mouseDownOffsetX 鼠标按下时鼠标在画布上的偏移量
// this.scale 画布的缩放比例
return {
x: item.x + (event.x - this.mouseDownOffsetX) / this.scale,
y: item.y + (event.y - this.mouseDownOffsetY) / this.scale,
}
})
}
四、性能优化
在图形移动的过程中,我们只移动了我们选中的图形,但是,我们依然会渲染画布可视区所有的图形。这带来了很多额外的渲染开销,我们需要优化它。
我们使用 动静分离
的方式来优化这个问题。具体就是将画布拆分为两个,底下的画布用来绘制所有没有发生移动的图形,只绘制一次。上面的画布用来绘制我们选中的图形,我们只需要在上面的画布渲染一个图形,可以极大的降低性能开销。
1. 创建第二张画布
// 在初始化的时候,创建第二张画布,用于绘制移动的图形
// 1. 动态创建一个 Canvas 画布
this.renderCanvas = document.createElement('canvas');
// 2. 设置这个 Canvas 画布的 CSS 样式
this.renderCanvas.style.position = 'absolute';
this.renderCanvas.style.top = '0px';
this.renderCanvas.style.left = '0px';
this.renderCanvas.style.width = this.width + 'px';
this.renderCanvas.style.height = this.height + 'px';
// 3. 将两张画布的设置为相同大小
this.renderCanvas.width = this.width * this.ratio;
this.renderCanvas.height = this.height * this.ratio;
// 4. 获取上下文
this.renderCtx = this.renderCanvas.getContext('2d') as CanvasRenderingContext2D;
this.renderCtx.scale(this.ratio, this.ratio);
// 5. 将这张画布添加到页面,并作为上层画布,用来绘制选中的移动中的图形
this.canvas.parentElement?.insertBefore(this.renderCanvas, this.canvas)
2. 同步两张画布的渲染状态
/** 渲染函数 */
render = () => {
this.log_render && console.time('render')
/** 清空画布 */
this.renderCtx.clearRect(0, 0, this.width * this.ratio, this.height * this.ratio);
this.ctx.clearRect(0, 0, this.width * this.ratio, this.height * this.ratio);
/** 保存画布状态 */
this.renderCtx.save();
this.ctx.save();
/** 平移画布 */
this.renderCtx.translate(this.offsetX, this.offsetY);
this.ctx.translate(this.offsetX, this.offsetY);
/** 缩放画布 */
this.renderCtx.scale(this.scale, this.scale);
this.ctx.scale(this.scale, this.scale);
/** 渲染不移动的图形 */
this.renderGraph();
/** 移除画布状态 */
this.renderCtx.restore();
this.ctx.restore();
this.log_render && console.timeEnd('render')
}
3. 渲染图形
- 渲染不是移动状态的图形,将其渲染到 renderCanvas 画布上。
this.graphs.forEach((graph) => { let isShow = false; // 判断图形上是否有端点在可视区 graph.points.forEach(point => { if (this.isPointInGraph(point, points)) { isShow = true; } }) // 如果所有端点都没有在画布可视区,不渲染 // 如果当前图形是选中状态,不渲染 if (!isShow || graph.select) return; this.drawGraph(this.renderCtx, graph); })
- 渲染要移动的图形,将其渲染到 canvas 画布上。
renderActive = (selectGraph = this.selectGraph) => { this.log_render && console.time('render') if (selectGraph) { this.ctx.clearRect(0, 0, this.width * this.ratio, this.height * this.ratio); this.ctx.save(); this.ctx.translate(this.offsetX, this.offsetY); this.ctx.scale(this.scale, this.scale); this.drawGraph(this.ctx, selectGraph); this.ctx.restore(); } this.log_render && console.timeEnd('render') }