以前遇到過一個面試的機試題,就是用畫布繪製形狀,而且支持縮放、拖拽功能。如今有點時間就分享一下我是如何一步一步完成這個功能的。看這篇信息以前最好先去看一下canvas
的 api
,canvas API 穿梭機。
javascript
先寫出容器Dom,和樣式 htmlcss
<div id="chart-wrap" class="chart-wrap"></div>
複製代碼
csshtml
html,body {
margin: 0;
height: 100%;
overflow: hidden;
}
.chart-wrap {
height: calc(100% - 40px);
margin: 20px;
box-shadow: 0 0 3px orange;
}
複製代碼
這裏寫一個 名叫 chart
的類,在 構造器 constructor
裏初始化畫布,寫好繪製形狀的函數、以及畫布渲染。代碼以下:java
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
方法,繪製一個圓形。canvas
代碼效果點擊此處觀看
api
若是想繪製其餘圖形就須要加 type
判斷,以上代碼改造完成後以下:markdown
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
)。app
代碼效果點擊此處觀看
dom
添加縮放須要先理清一些東西。
縮放 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 中的例子修改參數看看效果。
以上若有問題或疏漏,歡迎指正,謝謝。