使用canvas 如何绘制形状并支持拖拽、缩放功能
使用canvas 如何绘制形状并支持拖拽、缩放功能引言开始编写首先绘制一个形状绘制多个、多种类型形状添加缩放功能引言 之前遇到过一个面试的机试题,就是用画布绘制形状,并且支持缩放、拖拽功能。现在有点时间就分享一下我是如何一步一步完成这个功能的。看这篇信息之前先取看一下canvas 的 api,canvas API 穿梭机。开始编写先写出容器Dom,和样式html<div id="char
使用canvas 如何绘制形状并支持拖拽、缩放功能
引言
之前遇到过一个面试的机试题,就是用画布绘制形状,并且支持缩放、拖拽功能。现在有点时间就分享一下我是如何一步一步完成这个功能的。看这篇信息之前最好先去看一下canvas
的 api
,canvas API 穿梭机。
开始编写
先写出容器Dom,和样式
html
<div id="chart-wrap" class="chart-wrap"></div>
css
html,body {
margin: 0;
height: 100%;
overflow: hidden;
}
.chart-wrap {
height: calc(100% - 40px);
margin: 20px;
box-shadow: 0 0 3px orange;
}
首先绘制一个形状
这里写一个 名叫 chart
的类,在 构造器 constructor
里初始化画布,写好绘制形状的函数、以及画布渲染。代码如下:
class chart {
// 初始构造器
constructor(params) {
var wrapDomStyle = getComputedStyle(params.el);
this.width = parseInt(wrapDomStyle.width, 10);
this.height = parseInt(wrapDomStyle.height, 10);
// 创建canvas画布
this.El = document.createElement('canvas');
this.El.height = this.height;
this.El.width = this.width;
this.ctx = this.El.getContext('2d');
params.el.appendChild(this.El);
}
// 绘制圆形
drawCircle(data) {
this.ctx.beginPath();
this.ctx.fillStyle = data.fillStyle;
this.ctx.arc(data.x, data.y, data.r, 0, 2 * Math.PI);
this.ctx.fill();
}
// 添加形状
push(data) {
this.drawCircle(data);
}
}
// 构建图表对象
var chartObj = new chart( { el: document.getElementById('chart-wrap') } );
// 绘制圆形
chartObj.push({
fillStyle: 'pink',
x: 400,
y: 300,
r: 50
});
上面代码结构很简单,new
一个对象,传入容器Dom,在constructor
中初始化一个画布放入 div#chart-wrap
这个 dom
中,再把创建好的实例赋值给 chartObj
这个变量。
通过调用类的 push
方法,绘制一个圆形。
绘制多个、多种类型形状
如果想绘制其他图形就需要加 type
判断,以上代码改造完成后如下:
class chart {
// 初始构造器
constructor(params) {
var wrapDomStyle = getComputedStyle(params.el);
this.width = parseInt(wrapDomStyle.width, 10);
this.height = parseInt(wrapDomStyle.height, 10);
// 创建canvas画布
this.El = document.createElement('canvas');
this.El.height = this.height;
this.El.width = this.width;
this.ctx = this.El.getContext('2d');
params.el.appendChild(this.El);
}
// 绘制圆形
drawCircle(data) {
this.ctx.beginPath();
this.ctx.fillStyle = data.fillStyle;
this.ctx.arc(data.x, data.y, data.r, 0, 2 * Math.PI);
this.ctx.fill();
}
// _____________ 添加绘制线条方法 ____________
drawLine(data) {
var arr = data.data.concat()
var ctx = ctx || this.ctx;
ctx.beginPath()
ctx.moveTo(arr.shift(), arr.shift())
ctx.lineWidth = data.lineWidth || 1
do{
ctx.lineTo(arr.shift(), arr.shift());
} while (arr.length)
ctx.stroke();
}
// ___________ 添加绘制矩形方法 ______________
drawRect(data) {
this.ctx.beginPath();
this.ctx.fillStyle = data.fillStyle;
this.ctx.fillRect(...data.data);
}
// ___________ 添加一个判断类型绘制的方法 _____________
draw(item) {
switch(item.type){
case 'line':
this.drawLine(item)
break;
case 'rect':
this.drawRect(item)
break;
case 'circle':
this.drawCircle(item)
break;
}
}
// 添加形状
push(data) {
this.draw(data); // ____________ 修改调用绘制方法 ____________
}
}
// 构建图表对象
var chartObj = new chart( { el: document.getElementById('chart-wrap') } );
// 绘制圆形
chartObj.push({
type: 'circle', // ____________ 这里添加了一个类型 __________________
fillStyle: 'pink',
x: 400,
y: 300,
r: 50
});
// ___________ 添加绘制线条 __________
chartObj.push({
type: 'line',
lineWidth: 4,
data: [100, 90, 200, 90, 250, 200, 400, 200]
})
// ___________ 添加绘制矩形 __________
chartObj.push({
type: 'rect',
fillStyle: "#0f00ff",
data: [350, 400, 100, 100]
})
对比前面这里添加了一个绘制矩形(drawRect
)、绘制线条(drawLine
)的方法 和 数据,并且添加了判断渲染类型的函数(draw
)。
添加缩放功能
添加缩放需要先理清一些东西。
缩放 canvas
提供了两个类型方法可以实现,一个是在当前缩放基础上缩放,一个是在基础画布上缩放。
矩阵变化不只有缩放,但是可以其他参数不变只更改缩放值
当前缩放基础上缩放:scale()
缩放当前绘图至更大或更小,transform()
替换绘图的当前转换矩阵;
意思就是原本画布大小是 1,第一次放大 2倍,就变成2,第二次放大2倍就变成4
在基础画布上缩放: setTransform()
将当前转换重置为单位矩阵。然后运行 transform()。
意思就是原本画布大小是 1,第一次放大 2倍,就变成2,第二次放大2倍还是2,因为重置回原来的1后再放大的
这里我使用 setTransform()
缩放画布
第一步骤:.因为要缩放所以必须保存好当前的缩放值,就在constructor
加以下参数,以及在 push()
方法下保存数据、render()
重绘所有数据
constructor() {
// 因为canvas是基于状态绘制的,也就是设置了缩放值,再绘制的元素才会根据缩放倍数绘制,因此需要把每个绘制的对象保存起来。
this.data = [];
this.scale = 1; // 默认缩放值是 1
}
// 添加形状
push(data) {
// push 方法中添加保存数据操作
this.data.push(data);
}
// 渲染整个 图形画布
render() {
this.El.width = this.width
this.data.forEach(item => {
this.draw(item)
})
}
第二步骤:.因为缩放时鼠标滚轮控制,所以加上监听滚轮事件,而且是在鼠标移入画布中时才添加,不在画布中就不需要监听滚轮事件。
constructor() {
// 添加滚轮判断事件
this.addScaleFunc();
}
// 添加缩放功能,判断时机注册移除MouseWhell事件
addScaleFunc() {
this.El.addEventListener('mouseenter', this.addMouseWhell);
this.El.addEventListener('mouseleave', this.removeMouseWhell);
}
// 添加 mousewhell 事件
addMouseWhell = () => {
document.addEventListener('mousewheel', this.scrollFunc, {passive: false});
}
// 移除mousewhell 事件
removeMouseWhell = () => {
document.removeEventListener('mousewheel', this.scrollFunc, {passive: false});
}
第三步骤:滚轮事件监听完成后,就是调用具体的缩放实现代码了
constructor() {
// 缩放具体实现会用到的数据
this.maxScale = 3; // 最大缩放值
this.minScale = 1; // 最小缩放值
this.step = 0.1; // 缩放率
this.offsetX = 0; // 画布X轴偏移值
this.offsetY = 0; // 画布Y轴偏移值
}
// 缩放 具体计算
scrollFunc = (e) => {
// 阻止默认事件 (缩放时外部容器禁止滚动)
e.preventDefault();
if(e.wheelDelta){
var x = e.offsetX - this.offsetX
var y = e.offsetY - this.offsetY
var offsetX = (x / this.scale) * this.step
var offsetY = (y / this.scale) * this.step
if(e.wheelDelta > 0){
this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY
this.scale += this.step
} else {
this.offsetX += this.scale <= this.minScale ? 0 : offsetX
this.offsetY += this.scale <= this.minScale ? 0 : offsetY
this.scale -= this.step
}
this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
this.render()
}
}
// 在类型判断渲染方法内添加设置缩放
draw() {
this.ctx.setTransform(this.scale,0, 0, this.scale, this.offsetX, this.offsetY);
}
解释:
第一步骤第二步骤理解起来很容易,比较麻烦的是第三步骤,下面就来详细解释一下第三部具体缩放实现。
缩减一下代码
scrollFunc = (e) => {
// 阻止默认事件 (缩放时外部容器禁止滚动)
e.preventDefault();
if(e.wheelDelta){
e.wheelDelta > 0 ? this.scale += this.step : this.scale -= this.step
this.render()
}
}
只需要上述几行就实现了缩放。判断 e.wheelDelta
是向上滚动还是向下,从而增加或减少 this.scale
的大小,最后调用 render()
重新绘制当前画布。
e.preventDefault()
就不多解释了,大家都知道是解决默认行为的。但是有一点要解释一下 在调用 scrollFunc()
这个函数的事件监听器的第三个参数 {passive: false}
是必须加的(默认就是 {passive: true}
),不然无法阻止默认的滚动事件。
大家可以在演示例子中注释掉 scrollFunc
中的其它代码查看效果,发现缩放是可以了,但是,却没有根据鼠标位置进行缩放,而是始终以画布(0,0)
的位置缩放。所以画布放大后会向右下偏移,因此需要向左和上偏移校正,使缩放看起来就像在鼠标位置缩放。
在上方代码上改造一下 代码如下:
scrollFunc = (e) => {
// 阻止默认事件 (缩放时外部容器禁止滚动)
e.preventDefault();
if(e.wheelDelta){
var x = e.offsetX - this.offsetX
var y = e.offsetY - this.offsetY
var offsetX = (x / this.scale) * this.step
var offsetY = (y / this.scale) * this.step
if(e.wheelDelta > 0){
this.offsetX -= offsetX
this.offsetY -= offsetY
this.scale += this.step
} else {
this.offsetX += offsetX
this.offsetY += offsetY
this.scale -= this.step
}
this.render()
}
}
x,y
是鼠标距离画布原始原点的距离,offsetX,offsetY
是本次缩放的偏移量,然后判断放大或者缩小从而增减整体画布的偏移量。
本次偏移量计算方式:鼠标距原始点距离(x,y)
除以 缩放值 this.scale
再乘以 缩放率 this.step
。
解释:因为是使用setTransform()
,所以每次放大或者缩小都是在原始画布大小的基础上缩放,所以需要除以缩放值,找到在原始缩放基础上鼠标距离原始点的距离。
解释:如果使用scale()
,就不需要除以缩放值,直接当前缩放值乘以缩放率就能等于现在实际缩放值
最后再把缩放功能完善,添加最大缩放值this.maxScale
和 最小缩放值 this.minScale
限制,完成代码如下:
// 缩放 具体计算
scrollFunc = (e) => {
// 阻止默认事件 (缩放时外部容器禁止滚动)
e.preventDefault();
if(e.wheelDelta){
var x = e.offsetX - this.offsetX
var y = e.offsetY - this.offsetY
var offsetX = (x / this.scale) * this.step
var offsetY = (y / this.scale) * this.step
if(e.wheelDelta > 0){
this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY
this.scale += this.step
} else {
this.offsetX += this.scale <= this.minScale ? 0 : offsetX
this.offsetY += this.scale <= this.minScale ? 0 : offsetY
this.scale -= this.step
}
this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
this.render()
}
}
以上缩放值计算就完成了,最后只需调用 this.render()
,在this.render
中会调用 this.draw
函数,这个函数里调用setTransform
方法,这里会将更改后的缩放值,以及偏移值设置到画布中。
this.ctx.setTransform(this.scale,0, 0, this.scale, this.offsetX, this.offsetY);
添加拖拽画布的效果
首先理清一下拖拽的步骤
鼠标按下 => 鼠标移动 => 鼠标放开
鼠标按下:我们用 mousedown
事件,然后在按下事件中注册 鼠标移动 事件
鼠标移动:我们用 mousemove
事件,在鼠标移动事件中 具体实现画布移动
鼠标放开:我们用 mouseup
事件,在鼠标放开事件中 删除 鼠标移动 事件
具体代码如下:
constructor(params) {
this.wrapDom = params.el;
this.addDragFunc();
}
// 添加拖拽功能,判断时机注册移除 拖拽 功能
addDragFunc() {
this.El.addEventListener('mousedown', this.addMouseMove);
document.addEventListener('mouseup', this.removeMouseMove);
}
// 添加鼠标移动 功能,获取保存当前点击坐标
addMouseMove = (e) => {
this.targetX = e.offsetX
this.targetY = e.offsetY
this.mousedownOriginX = this.offsetX;
this.mousedownOriginY = this.offsetY;
this.wrapDom.style.cursor = 'grabbing'
this.El.addEventListener('mousemove', this.moveCanvasFunc, false)
}
// 移除鼠标移动事件
removeMouseMove = () => {
this.wrapDom.style.cursor = ''
this.El.removeEventListener('mousemove', this.moveCanvasFunc, false)
this.El.removeEventListener('mousemove', this.moveShapeFunc, false)
}
// 移动画布
moveCanvasFunc = (e) => {
// 获取 最大可移动宽
var maxMoveX = this.El.width / 2;
var maxMoveY = this.El.height / 2;
var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);
this.offsetX = Math.abs(offsetX) > maxMoveX ? this.offsetX : offsetX
this.offsetY = Math.abs(offsetY) > maxMoveY ? this.offsetY : offsetY
this.render()
}
其它代码都很简单,这里就详细解释一下 addMouseMove()
和 moveCanvasFunc()
做了哪些操作。
addMouseMove
函数中 使用 targetX,targetY
保存了鼠标点击时的坐标,mousedownOriginX ,mousedownOriginX
保存了鼠标点击时 画布的整体偏移量。
再在 moveCanvasFunc
函数中 计算出移动后的整体偏移量,moveCanvasFunc
函数中的代码可以简化成这样:
moveCanvasFunc = (e) => {
var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);
this.render()
}
其他代码是为了限制偏移量的最大值,最后调用this.render()
整体来讲,拖拽画布功能比缩放稍微简单一些,同样这里最后会调用 this.render()
,在this.render
中会调用 this.draw
函数,这个函数里调用了setTransform
方法,这里会将更改后的缩放值,以及偏移值设置到画布中。
this.ctx.setTransform(this.scale,0, 0, this.scale, this.offsetX, this.offsetY);
拖拽画布中的形状
如果要拖拽画布中的形状,需要判断鼠标点击的位置是否处于形状中,而且因为层级关系,只能控制顶层的形状。
因此需要写鼠标按下时是否处于形状内部的判断方法,这里我们只写了矩形、圆形、线段的判断方法。
因为之前已经在实现画布拖拽的时候,实现了拖拽功能,现在只需要要改造 addMouseMove
函数 和添加 形状移动 函数,以及三个判断方法。
整体代码如下:
// 添加鼠标移动 功能,获取保存当前点击坐标
addMouseMove = (e) => {
this.targetX = e.offsetX
this.targetY = e.offsetY
this.mousedownOriginX = this.offsetX;
this.mousedownOriginY = this.offsetY;
var x = (this.targetX - this.offsetX) / this.scale;
var y = (this.targetY - this.offsetY) / this.scale;
this.activeShape = null
this.data.forEach(item => {
switch(item.type){
case 'rect':
this.isInnerRect(...item.data, x, y) && (this.activeShape = item)
break;
case 'circle':
this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
break;
case 'line':
var lineNumber = item.data.length / 2 - 1
var flag = false
for(let i = 0; i < lineNumber; i++){
let index = i*2;
flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
if(flag){
this.activeShape = item
break;
}
}
}
})
if(!this.activeShape){
this.wrapDom.style.cursor = 'grabbing'
this.El.addEventListener('mousemove', this.moveCanvasFunc, false)
} else {
this.wrapDom.style.cursor = 'all-scroll'
this.shapedOldX = null
this.shapedOldY = null
this.El.addEventListener('mousemove', this.moveShapeFunc, false)
}
}
// 移动形状
moveShapeFunc = (e) => {
var moveX = e.offsetX - (this.shapedOldX || this.targetX);
var moveY = e.offsetY - (this.shapedOldY || this.targetY);
moveX /= this.scale
moveY /= this.scale
switch(this.activeShape.type){
case 'rect':
let x = this.activeShape.data[0]
let y = this.activeShape.data[1]
let width = this.activeShape.data[2]
let height = this.activeShape.data[3]
this.activeShape.data = [x + moveX, y + moveY, width, height]
break;
case 'circle':
this.activeShape.x += moveX
this.activeShape.y += moveY
break;
case 'line':
var item = this.activeShape;
var lineNumber = item.data.length / 2
for(let i = 0; i < lineNumber; i++){
let index = i*2;
item.data[index] += moveX
item.data[index + 1] += moveY
}
}
this.shapedOldX = e.offsetX
this.shapedOldY = e.offsetY
this.render()
}
// 判断是否在矩形框内
isInnerRect(x0, y0, width, height, x, y) {
return x0 <= x && y0 <= y && (x0 + width) >= x && (y0 + height) >= y
}
// 判断是否在圆形内
isInnerCircle(x0, y0, r, x, y) {
return Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2) <= Math.pow(r, 2)
}
// 判断是否在路径上
isInnerPath(x0, y0, x1, y1, x, y, lineWidth) {
var a1pow = Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2);
var a1 = Math.sqrt(a1pow, 2)
var a2pow = Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2)
var a2 = Math.sqrt(a2pow, 2)
var a3pow = Math.pow(x1 - x0, 2) + Math.pow(y1 - y0, 2)
var a3 = Math.sqrt(a3pow, 2)
var r = lineWidth / 2
var ab = (a1pow - a2pow + a3pow) / (2 * a3)var ab = (a1pow - a2pow + a3pow) / (2 * a3)
var h = Math.sqrt(a1pow - Math.pow(ab, 2), 2)
var ad = Math.sqrt(Math.pow(a3, 2) + Math.pow(r, 2))
return h <= r && a1 <= ad && a2 <= ad
}
以上代码在 addMouseMove
中加入了判断是否处于形状内部的操作。
var x = (this.targetX - this.offsetX) / this.scale;
var y = (this.targetY - this.offsetY) / this.scale;
this.activeShape = null
this.data.forEach(item => {
switch(item.type){
case 'rect':
this.isInnerRect(...item.data, x, y) && (this.activeShape = item)
break;
case 'circle':
this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
break;
case 'line':
var lineNumber = item.data.length / 2 - 1
var flag = false
for(let i = 0; i < lineNumber; i++){
let index = i*2;
flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
if(flag){
this.activeShape = item
break;
}
}
}
})
根据鼠标位置获取到基于原始缩放状态下距离画布原点的x,y
坐标,根据不同 type
调用不同方法判断是否处于当前形状中。
然后根据是否处于形状内部判断注册 拖拽画布 还是 拖拽形状 的事件
if(!this.activeShape){
this.wrapDom.style.cursor = 'grabbing'
this.El.addEventListener('mousemove', this.moveCanvasFunc, false)
} else {
this.wrapDom.style.cursor = 'all-scroll'
this.shapedOldX = null
this.shapedOldY = null
this.El.addEventListener('mousemove', this.moveShapeFunc, false)
}
如果处于形状内部,就修改形状位置参数,并调用 this.render()
,重新渲染画布
// 移动形状
moveShapeFunc = (e) => {
var moveX = e.offsetX - (this.shapedOldX || this.targetX);
var moveY = e.offsetY - (this.shapedOldY || this.targetY);
moveX /= this.scale
moveY /= this.scale
switch(this.activeShape.type){
case 'rect':
let x = this.activeShape.data[0]
let y = this.activeShape.data[1]
let width = this.activeShape.data[2]
let height = this.activeShape.data[3]
this.activeShape.data = [x + moveX, y + moveY, width, height]
break;
case 'circle':
this.activeShape.x += moveX
this.activeShape.y += moveY
break;
case 'line':
var item = this.activeShape;
var lineNumber = item.data.length / 2
for(let i = 0; i < lineNumber; i++){
let index = i*2;
item.data[index] += moveX
item.data[index + 1] += moveY
}
}
this.shapedOldX = e.offsetX
this.shapedOldY = e.offsetY
this.render()
}
移动形状同样也是要获取到基于原始缩放大小(可以看到上方除了this.scale
)的画布的移动量 moveX,moveY
,再将移动量增加至 选中形状的位置坐标中。
保存好当前偏移量 this.shapedOldX,this.shapedOldY
,供下次事件触发使用。
判断是否处于形状内部方法解释
1.判断是否处于矩形框内
根据当前计算出的 x,y
坐标,判断是否小于 矩形的x,y
坐标,并且判断是否大于矩形 (x + width)
与 (y + height)
的右下角坐标。
2.判断是否处于圆形内
根据当前计算出的 x,y
坐标,计算出距离圆心 坐标的距离,如果小于等于圆的半径,就说明处于圆形内部。
3.判断是否处于线段中
假设线段 AB(线段粗为90),鼠标点击点为C,判断AC 或 BC 是否大于 AD,如果大于C,肯定不处于线段内,并且C与AB 的垂直距离CH必须小于等于 线段宽度的一半。
这里只支持单个线段判断,多个连接线段判断不精确,连接处会有多余部分无法判断。
如下图:
这是宽度为90的线段,红色区域上述方法能判断,箭头指向部分无法判断。
这里暂时不考虑也是因为如果 线段之间的夹角小于 90deg
,默认形状会是:
可以看 miterLimit 属性 和 lineJoin 属性 以及 lineCap 属性,这些属性对线段影响较大,这里只做默认状态下单条线段判断演示。
总结
OK,以上就已经把最开始讲的需求做完了,有兴趣的朋友可以更改Demo 中的例子修改参数看看效果。
以上如有问题或疏漏,欢迎指正,谢谢。
更多推荐
所有评论(0)