使用canvas 如何繪製形狀並支持拖拽、縮放功能

引言

   以前遇到過一個面試的機試題,就是用畫布繪製形狀,而且支持縮放、拖拽功能。如今有點時間就分享一下我是如何一步一步完成這個功能的。看這篇信息以前最好先去看一下canvasapicanvas 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 中的例子修改參數看看效果。

以上若有問題或疏漏,歡迎指正,謝謝。

相關文章
相關標籤/搜索