canvas详解(2)-事件处理
上一章我们讲解了canvas
的基本原理应用,这一章主要讲解一下事件如何处理。 canvas详解(1)-原理
canvas
因为是画布的原因,实际上我们可以将它当做一张图片,所以在html
中,无论这个canvas
包含多少元素,多少图形,他都只有一个tag显示。那么它当中的图形事件也只能添加到canvas
元素自身上了。
既然所有的的事件都只能添加到canvas
元素上,那么怎么区分各个图形的事件呢?
根据坐标,根据每个图形在画布上不同的坐标来区分每个图形对应的事件。这就需要大量的判断以及对坐标的精确定位。canvas
提供了isPointInPath(x,y)
方法来判断坐标是否在当前路径上。
canvas的事件响应主要依赖于重绘,我们绘制的图形一般都是静态的,在很多时候会根据事件不同而呈现不同的效果,那么这些效果即是清除整个画布(
clearRect()
),重绘整个新的效果图。当然如果只是简单的响应,只要能精确定位坐标,能不重绘还是尽量不要重绘,毕竟重绘是相当耗时的。
以上一节的例子为例,我详细讲解下饼图鼠标移动到环形上给出提示的操作方法。
思路
1.事件选取:mousemove
鼠标移动事件,wheel
鼠标滚动事件,都添加到canvas
元素上。
2.鼠标定位:必须将屏幕上鼠标的位置转化为画布上的坐标位置,这样鼠标的位置才能代表画布的位置,也才具有代表画布上某个图形的能力。
3.位置判断:鼠标放在了饼图某一部分,也就是说鼠标只在外层圆的某一部分上且不在内层圆上。
canvas
的isPointInPath()
只能判断指定的点是否在当前绘制路径,也就是说,我们是先画外层圆后画内层圆的,我们只能判断出点是否位于外层圆的某一个部分,但不能同时判断是否不在内层圆上。那么我们只有先判断鼠标位置是否位于圆环上,再用
isPointInPath
来判断具体位于哪一个圆弧部分。
4.重绘:重绘整个效果图,当鼠标位于饼图某一部分的时候,对这一部分单独绘制,使之的外层圆半径比之前大5px,这样鼠标移入的部分就会凸显出来。
5.添加提示:在canvas的同级添加提示消息,显示饼图的相关信息。
具体实现
1.事件选取
canvas.addEventListener('mousemove', (e) => {
let eventX = e.clientX - canvas.getBoundingClientRect().left;
let eventY = e.clientY - canvas.getBoundingClientRect().top;
let mousePoint = { x: eventX, y: eventY, clientX: e.clientX, clientY: e.clientY };
let isRingRange = this.isRingPostion(mousePoint, data, this.props.nodeWidth,
this.props.innerRadius, this.props.radius, scale);
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawNode(ctx, data, data.x, data.y, this.props.nodeWidth, this.props.nodeHeight,
this.props.radius, this.props.innerRadius, this.state.color, scale, mousePoint, isRingRange, this);
if (!isRingRange) {
this.setState({ tipNode: null });
}
}, false)
在重绘时一定要清除画布,否则前后2次的绘制将相互重叠影响效果。
canvas.addEventListener('wheel', (e) => {
this.setState({ tipNode: null });
})
该事件的选取在最开始并未被考虑,但是后来发现鼠标滚动时,提示显示随之滚动,不消失,所以添加了该事件,目的是鼠标滚动时,直接关闭提示。
2.鼠标定位
事件获取的位置是相对于浏览器屏幕的位置,而我们需要的是鼠标相对于画布的位置。getBoundingClientRect()
可以返回canvas
相对于屏幕的位置。
let eventX = e.clientX - canvas.getBoundingClientRect().left;//鼠标相对于画布的X轴坐标
let eventY = e.clientY - canvas.getBoundingClientRect().top;//鼠标相对于画布的Y轴坐标
let mousePoint = { x: eventX, y: eventY, clientX: e.clientX, clientY: e.clientY };//记录鼠标的位置
3.环形区域判断
/*点击位置是否在圆环上(数据为列表)
*mousePoint:鼠标对象位置记录
*node:详细数据
*nodeWidth:矩形的宽度
*innerRadius:外层圆半径
*radius:内层圆半径
*scale:缩放比例
*/
isRingPostion(mousePoint, node, nodeWidth, innerRadius, radius, scale) {
if (!mousePoint) {
return false;
}
nodeWidth = nodeWidth * scale;
innerRadius = innerRadius * scale;
radius = radius * scale;
let eventX = mousePoint.x;
let eventY = mousePoint.y;
//点击位置到圆心的距离,勾股定理计算
let cricleX = node.x + nodeWidth / 4;//圆心x坐标
let cricleY = node.y;
let distanceFromCenter = Math.sqrt(Math.pow(cricleX - eventX, 2)
+ Math.pow(cricleY - eventY, 2))
//是否在圆环上
if (distanceFromCenter > innerRadius && distanceFromCenter < radius) {
return true;
}
return false;
}
这里用到了2点之间的距离,鼠标位置到圆心的距离。如果该距离小于外层圆半径并且大于内层圆半径,那么该鼠标位置位于圆环内部。
需要说明一下:矩形的中心位置即是节点数据的x和y坐标,矩形被分为了左右对称的2部分,左边部分显示文字,右边部分显示圆环。而圆环的中心位置位于右边部分的中点,即是
node.x + nodeWidth / 4
的位置。为什么不用直接使用nodeWidth *3 / 4
,因为如果存在多个节点则无法判断圆心的坐标了。
4.重绘
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawNode(ctx, data, data.x, data.y, this.props.nodeWidth, this.props.nodeHeight,
this.props.radius, this.props.innerRadius, this.state.color, scale, mousePoint, isRingRange, this);
5.鼠标具体位置判断
在绘制外层圆弧时判断。
if (mousePoint && ctx.isPointInPath(mousePoint.x, mousePoint.y) && isRingRange) {//鼠标点击了并且在该部分圆环上
ctx.clearRect(-radius, -radius, 2 * radius, 2 * radius);
this.drawDynamicPie(ctx, node, radius, color, i);//重绘圆
let tipNode = {
desc: node.desc,
name: node.pieData[i].name,
value: this.floatMul(node.pieData[i].value, 100) + "%"
}
treePage.setState({ tipNode: tipNode, mousePosition: { x: mousePoint.clientX, y: mousePoint.clientY } });
}
当鼠标位置位于该部分圆弧上,且鼠标位置在圆环上时,是我们选取的环形图某部分。
在这个时候,我们对选取的部分进行单独重绘,使之凸出显示,并且记录应该出现的提示信息。
//绘制动态圆
drawDynamicPie(ctx, node, radius, color, index) {
let startRadian = 0, endRadian = 0;
for (let i = 0; i < node.pieData.length; i++) {
ctx.beginPath();
//起始点移动到圆心
ctx.moveTo(0, 0);
endRadian += node.pieData[i].value * Math.PI * 2;
//以圆心为起点,0度开始绘制一个圆
if (index == i) {
ctx.arc(0, 0, radius + 5, startRadian, endRadian, false);
} else {
ctx.arc(0, 0, radius, startRadian, endRadian, false);
}
ctx.closePath();
// 填充颜色
ctx.fillStyle = color[i];
ctx.fill();
startRadian = endRadian;
}
}
6.显示提示
提示消息显示的位置在鼠标位置偏右12px,偏下12px。但这个时候发现一个新的问题,当提示框在浏览器边缘的时候,我们的提示框被挤压的太难看了。所以我们先对提示框位置进行定位,使之不能展示的时候自动调节。
//获取提示的定位位置
getTipPosition() {
let tipDiv = document.getElementById(`${this.props.treeId}Tip`);
let mousePosition = this.state.mousePosition;
let top1 = mousePosition.y + 12;
let left = mousePosition.x + 12;
if (tipDiv) {
if (mousePosition.x + tipDiv.offsetWidth > window.innerWidth) {
left = mousePosition.x - 12 - tipDiv.offsetWidth;
}
if (mousePosition.y + tipDiv.offsetHeight > window.innerHeight) {
top1 = mousePosition.y - 12 - tipDiv.offsetHeight;
}
}
return { top: top1, left: left }
}
这里用到提示框自身的宽高了,所以我们不能用
display:none
,它将不能获取提示框的宽高,而visibility:hidden
即使在隐藏的时候也占有位置,可以被我们利用。
render() {
let position = this.getTipPosition();
let tipClass = {
position: 'fixed',
zIndex: 999,
visibility: this.state.tipNode ? 'visible' : 'hidden',
backgroundColor: '#826d6d',
top: position.top,
left: position.left,
padding: '15px',
color: '#fff',
borderRadius: '5px',
textAlign: 'left'
}
return (
<div style={{ padding: 100 }}>
<canvas id={this.props.canvasId} width={this.props.width} height={this.props.height} style={{ zoom: this.state.ratio }}></canvas>
<div style={tipClass} id={`${this.props.treeId}Tip`}>
<div>{this.state.tipNode ? this.state.tipNode.desc : null}</div>
<div>{this.state.tipNode ? this.state.tipNode.name : null} : {this.state.tipNode ? this.state.tipNode.value : null}</div>
</div>
</div>
);
}
完整代码参考: canvas详解(1)-原理