JS利用原生canvas實(shí)現(xiàn)圖形標(biāo)注功能
由于工作需要,項(xiàng)目中要求實(shí)現(xiàn)一個(gè)功能—在視頻或者圖片上進(jìn)行圖形標(biāo)注,支持矩形、多邊形、線段、圓形、折線,已繪制的圖形可以進(jìn)行縮放,移動(dòng)。
完整功能源代碼在這個(gè)倉(cāng)庫(kù),感興趣的可以clone下來(lái)跑一下。
接下來(lái)我將實(shí)現(xiàn)一個(gè)dmeo來(lái)展示其中最簡(jiǎn)單的圖形—矩形的創(chuàng)建。
初始化頁(yè)面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
border: 1px solid saddlebrown;
background-color: beige;
}
</style>
</head>
<body>
<canvas id="canvasIdChart" class="canvas">您的瀏覽器不支持canvas1元素</canvas>
<script src="./drawClass.js"></script>
<script>
const chart = new Chart('canvasIdChart')
</script>
</body>
</html>該頁(yè)面只有一個(gè)canvas,而功能的實(shí)現(xiàn)在drawClass里面,這里我實(shí)現(xiàn)了一個(gè)Chart類,需要傳入一個(gè)canvas的id,表示后續(xù)的繪制功能都是在這個(gè)canvas上實(shí)現(xiàn)的。
創(chuàng)建基礎(chǔ)類Chart
首先梳理下要實(shí)現(xiàn)一個(gè)圖形標(biāo)注需要涉及到什么事件
用戶在canvas上按下鼠標(biāo),說(shuō)明用戶想要開(kāi)始畫(huà)一個(gè)矩形進(jìn)行標(biāo)注了,所以canvas上需要綁定一個(gè)onmousedown 事件。相應(yīng)的繪制過(guò)程也涉及到了onmousemove 事件,而用戶松開(kāi)鼠標(biāo)表示繪制已完成,即需要onmouseup事件。同時(shí)為了兼容這樣一種情況—用戶在繪制過(guò)程中鼠標(biāo)移出了canvas的范圍(此時(shí)的鼠標(biāo)事件都是綁定在canvas上的),這種情況無(wú)法觸發(fā)canvas的onmouseup事件,所以為canvas加上onmouseout,鼠標(biāo)移出默認(rèn)代表繪制完成。

初始化代碼如下
class Chart {
constructor(canvasId) {
this.cvs = document.getElementById(canvasId)
this.ctx = this.cvs.getContext('2d')
this.shapes = [] // 保存圖形數(shù)據(jù)的數(shù)組
this.init() // 初始化canvas得寬高
this.bindEvent() // 為canvas綁定鼠標(biāo)事件
this.draw() // canvas的繪制
this.isClickDown = false // 當(dāng)前鼠標(biāo)是否按下
this.currentShape = null // 當(dāng)前選中的圖形
}
init() {
const w = 1000,
h = 600
this.cvs.width = w
this.cvs.height = h
}
bindEvent() {
this.cvs.onmousedown = (e) => {
this.isClickDown = true
// 鼠標(biāo)按下的時(shí)候創(chuàng)建其他鼠標(biāo)事件
that.cvs.onmousemove = function (e){}
that.cvs.onmouseup = function (e){}
that.cvs.onmouseout = function (e){}
}
}
draw() {}
}
為了后續(xù)拓展,我將各種圖形封裝成一個(gè)個(gè)類,如矩形封裝成class **Rectangle 。這樣做的好處是我們只需要規(guī)定這樣的圖形類內(nèi)部都有某些屬性和方法給Chart中的draw調(diào)用就行,比如我們可以把繪制矩形的實(shí)現(xiàn)寫(xiě)在Rectangle類的draw函數(shù)上,而在Chart中的draw上只需調(diào)用new Rectangle().draw() 即可。要注意的是,draw方法是隨著mousemove而要不斷的刷新canvas重新繪制的,此時(shí)最好使用requestAnimationFrame方法讓瀏覽在合適的時(shí)機(jī)調(diào)用draw。此時(shí)Chart的draw方法如下:**
class Chart {
....
draw() {
requestAnimationFrame(this.draw.bind(this))
this.ctx.clearRect(0, 0, this.cvs.width, this.cvs.height) // 首先清空canvas
// 將shapes里面的圖形重新繪制
for (const s of this.shapes) {
s.draw()
}
}
}
實(shí)現(xiàn)Rectangle類
首先看一下矩形需要的幾點(diǎn)要素
- 填充的顏色
- 矩形的初始位置(即鼠標(biāo)按下的位置)
- 矩形的終點(diǎn)位置(即鼠標(biāo)抬起的位置,也是鼠標(biāo)移動(dòng)的最后位置)
- 矩形需要繪制在哪里(此時(shí)需要繪制在canvas上)
- 矩形的初始位置可能會(huì)大于終點(diǎn)位置(鼠標(biāo)按下后往左上角繪制),此時(shí)可用class語(yǔ)法中的get方法獲取minX、maxX、minY、maxY
- 因?yàn)橥悗缀螆D形的實(shí)例可能會(huì)有很多,所以每個(gè)實(shí)例需要一個(gè)id
初始代碼如下:
/**
* 生成唯一ID
* @param {Number} length 生成id的長(zhǎng)度
* @returns
*/
const genID = (length = 3) => {
return Number(Math.random().toString().substr(3, length) + Date.now()).toString(36)
}
class Rectangle {
constructor(el,color, startX, startY) {
this.el = el // 保存需要繪制的載體
this.shapeType = 'RECT' // 當(dāng)前幾何圖形的類別
this.color = color // 填充的顏色
// 初始化起點(diǎn),默認(rèn)起點(diǎn)和終點(diǎn)一樣
this.startX = startX
this.startY = startY
this.endX = startX
this.endY = startY
this.action = 'CREATE' // 當(dāng)前的操作類型
this.id = genID() // 當(dāng)前實(shí)例的唯一id
}
get minX() {
return Math.min(this.startX, this.endX)
}
get maxX() {
return Math.max(this.startX, this.endX)
}
get minY() {
return Math.min(this.startY, this.endY)
}
get maxY() {
return Math.max(this.startY, this.endY)
}
draw() {}
}
實(shí)現(xiàn)Rectangle類的draw函數(shù) 幸運(yùn)的是canvas得API中已經(jīng)有了繪制矩形的方法,不用我們一條線一條線的自己畫(huà)了。該方法是 canvas.rect(startX,startY,width,height) 四個(gè)參數(shù)分別是矩形的起點(diǎn)橫坐標(biāo)和縱坐標(biāo),矩形的寬高,這也是為什么需要minX和minY。代碼如下:
class Rectangle {
...
draw() {
this.el.beginPath() // 畫(huà)筆起始點(diǎn)
this.el.rect(
this.minX,
this.minY,
(this.maxX - this.minX),
(this.maxY - this.minY)
)
this.el.fillStyle = this.color // 填充顏色
this.el.fill()
this.el.strokeStyle = '#fff' // 邊框的顏色
this.el.lineCap = 'square'
this.el.lineWidth = 3 // 邊框粗細(xì)
this.el.stroke() // 畫(huà)筆終點(diǎn)
}
}
判斷鼠標(biāo)是否點(diǎn)擊在矩形內(nèi)部,用來(lái)判斷是新建還是拖動(dòng)矩形。 判斷一個(gè)坐標(biāo)點(diǎn)是否落在矩形的內(nèi)部很好判斷,就是通過(guò)minX、minY、maxX、maxY和傳入的x、y對(duì)比就可以了
class Rectangle {
...
isInside(x, y) {
return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY
}
}
實(shí)現(xiàn)鼠標(biāo)事件
在鼠標(biāo)事件中,獲取鼠標(biāo)的坐標(biāo)點(diǎn)屬性有好幾種,例如clientX/screenX/offsetX/pageX等,如需分別這幾個(gè)屬性的不同,可參考此篇文章,目前我用的是offsetX,因?yàn)檫@個(gè)屬性是獲取相對(duì)于事件對(duì)象的位置,此時(shí)事件對(duì)象就是canvas。

onmousedown事件 在mousedown事件我們需要做兩件事
判斷鼠標(biāo)落點(diǎn)是否在已有矩形的內(nèi)部,如果有就是當(dāng)前矩形的拖拽事件,如果不是則是新建矩形。 判斷是否落在集合圖形的內(nèi)部前面我們?cè)趲缀螆D形類中已經(jīng)約定好了一個(gè)isInside方法,此時(shí)只需遍歷shapes數(shù)組,依次調(diào)用每一項(xiàng)的isInside 方法就好。
...
// 遍歷數(shù)組
getShapes(x, y) {
for (let i = this.shapes.length - 1; i >= 0; i--) {
const s = this.shapes[i]
if (s.isInside(x, y)) {
return s
}
}
return null
}
如果返回的是null,則進(jìn)入新建邏輯。新建一個(gè)矩形的操作很簡(jiǎn)單,就是創(chuàng)建一個(gè)Rectangle的實(shí)例,new Rectangle(...) 并把new出來(lái)的實(shí)例添加進(jìn)shapes數(shù)組中。并且此時(shí)的onmousemove事件也很簡(jiǎn)單,只需把實(shí)例中的endX、endY修改成當(dāng)前move的坐標(biāo)點(diǎn)。
// 新建
const shape = new Rectangle(that.ctx, '#f00', clickX, clickY)
that.shapes.push(shape)
that.cvs.onmousemove = function (e) {
shape.endX = e.offsetX
shape.endY = e.offsetY
}
此時(shí)如果返回的不是null,說(shuō)明找到了當(dāng)前鼠標(biāo)點(diǎn)擊的圖形,執(zhí)行的是拖拽邏輯。而拖拽邏輯需要計(jì)算矩形被拖拽的偏移量,如下圖所示:

此時(shí)該矩形的四個(gè)坐標(biāo)都要進(jìn)行相應(yīng)的同步修改,并且還要判斷是否移動(dòng)超出了邊界。
完整代碼如下所示:
class Chart{
...
bindEvent() {
const that = this
this.cvs.onmousedown = function (e) {
this.isClickDown = true
const [clickX, clickY] = [e.offsetX, e.offsetY]
const shape = that.getShapes(clickX, clickY)
if (shape) {
// 如果找到圖形,說(shuō)明是拖拽
shape.action = 'MOVE'
const { startX, startY, endX, endY } = shape
that.cvs.onmousemove = function (e) {
const disX = e.offsetX - clickX
const disY = e.offsetY - clickY
const newStartX = startX + disX
const newEndX = endX + disX
const newStartY = startY + disY
const newEndY = endY + disY
// 判斷是否超出邊界(矩形)
if (newStartX < 0 || newEndX > that.cvs.width || newStartY < 0 || newEndY > that.cvs.height) {
return
}
shape.startX = newStartX
shape.endX = newEndX
shape.startY = newStartY
shape.endY = newEndY
}
} else {
// 沒(méi)找到,則是新建圖形
const shape = new Rectangle(that.ctx, '#f00', clickX, clickY)
that.shapes.push(shape)
that.cvs.onmousemove = function (e) {
shape.endX = e.offsetX
shape.endY = e.offsetY
}
}
}
getShapes(x, y) {
for (let i = this.shapes.length - 1; i >= 0; i--) {
const s = this.shapes[i]
if (s.isInside(x, y)) {
return s
}
}
return null
}
}
鼠標(biāo)按下初始化onmousemove和onmouseup等事件。 onmousemove、onmouseup和onmouseout事件其實(shí)很簡(jiǎn)單,只需要取消鼠標(biāo)移動(dòng)事件和鼠標(biāo)抬起事件即可
that.cvs.onmouseup = function () {
this.isClickDown = false
that.cvs.onmousemove = null
that.cvs.onmouseup = null
}
that.cvs.onmouseout = function () {
this.isClickDown = false
that.cvs.onmousemove = null
that.cvs.onmouseup = null
}
最終代碼
此時(shí),一個(gè)矩形的簡(jiǎn)單繪制和拖拽就完成了,后續(xù)的縮放需要的還可以單獨(dú)開(kāi)一篇文章講講。按照這種方式,其他幾何圖形我們可以新建相對(duì)應(yīng)地類就行了,拓展起來(lái)就很方便了。
完整代碼
// drawClass.js
/**
* 生成唯一ID
* @param {Number} length 生成id的長(zhǎng)度
* @returns
*/
const genID = (length = 3) => {
return Number(Math.random().toString().substr(3, length) + Date.now()).toString(36)
}
class Chart {
constructor(canvasId) {
this.cvs = document.getElementById(canvasId)
this.ctx = this.cvs.getContext('2d')
this.shapes = [] // 保存圖形數(shù)據(jù)的數(shù)組
this.init() // 初始化canvas得寬高
this.bindEvent() // 為canvas綁定鼠標(biāo)事件
this.draw() // canvas的繪制
this.isClickDown = false // 當(dāng)前鼠標(biāo)是否按下
this.isPolygon = false
this.currentShape = null // 當(dāng)前選中的圖形
}
init() {
const w = 1000,
h = 600
this.cvs.width = w
this.cvs.height = h
}
bindEvent() {
const that = this
this.cvs.onmousedown = function (e) {
this.isClickDown = true
const [clickX, clickY] = [e.offsetX, e.offsetY]
const shape = that.getShapes(clickX, clickY)
if (shape) {
// 如果找到圖形,說(shuō)明是拖拽
shape.action = 'MOVE'
const { startX, startY, endX, endY } = shape
that.cvs.onmousemove = function (e) {
const disX = e.offsetX - clickX
const disY = e.offsetY - clickY
const newStartX = startX + disX
const newEndX = endX + disX
const newStartY = startY + disY
const newEndY = endY + disY
// 判斷是否超出邊界(矩形)
if (newStartX < 0 || newEndX > that.cvs.width || newStartY < 0 || newEndY > that.cvs.height) {
return
}
shape.startX = newStartX
shape.endX = newEndX
shape.startY = newStartY
shape.endY = newEndY
}
} else {
// 沒(méi)找到,則是新建圖形
const shape = new Rectangle(that.ctx, '#f00', clickX, clickY)
that.shapes.push(shape)
that.cvs.onmousemove = function (e) {
shape.endX = e.offsetX
shape.endY = e.offsetY
const radius = Math.sqrt(
Math.pow(e.offsetX - clickX, 2) + Math.pow(e.offsetY - clickY, 2)
)
shape.radius = radius
}
}
that.cvs.onmouseup = function () {
this.isClickDown = false
that.cvs.onmousemove = null
that.cvs.onmouseup = null
}
that.cvs.onmouseout = function () {
this.isClickDown = false
that.cvs.onmousemove = null
that.cvs.onmouseup = null
}
}
}
getShapes(x, y) {
for (let i = this.shapes.length - 1; i >= 0; i--) {
const s = this.shapes[i]
if (s.isInside(x, y)) {
return s
}
}
return null
}
draw() {
requestAnimationFrame(this.draw.bind(this))
this.ctx.clearRect(0, 0, this.cvs.width, this.cvs.height) // 首先清空canvas
// 將shapes里面的圖形重新繪制
for (const s of this.shapes) {
s.draw()
}
}
}
class Rectangle {
constructor(el,color, startX, startY) {
this.el = el // 保存需要繪制的載體
this.shapeType = 'RECT' // 當(dāng)前幾何圖形的類別
this.color = color // 填充的顏色
// 初始化起點(diǎn),默認(rèn)起點(diǎn)和終點(diǎn)一樣
this.startX = startX
this.startY = startY
this.endX = startX
this.endY = startY
this.action = 'CREATE' // 當(dāng)前的操作類型
this.id = genID() // 當(dāng)前實(shí)例的唯一id
}
get minX() {
return Math.min(this.startX, this.endX)
}
get maxX() {
return Math.max(this.startX, this.endX)
}
get minY() {
return Math.min(this.startY, this.endY)
}
get maxY() {
return Math.max(this.startY, this.endY)
}
draw() {
this.el.beginPath() // 畫(huà)筆起始點(diǎn)
this.el.rect(
this.minX,
this.minY,
(this.maxX - this.minX),
(this.maxY - this.minY)
)
this.el.fillStyle = this.color // 填充顏色
this.el.fill()
this.el.strokeStyle = '#fff' // 邊框的顏色
this.el.lineCap = 'square'
this.el.lineWidth = 3 // 邊框粗細(xì)
this.el.stroke() //畫(huà)筆的終點(diǎn)
}
isInside(x, y) {
return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY
}
}
到此這篇關(guān)于JS利用原生canvas實(shí)現(xiàn)圖形標(biāo)注功能的文章就介紹到這了,更多相關(guān)JS canvas圖形標(biāo)注內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
原生JS實(shí)現(xiàn)九宮格抽獎(jiǎng)效果
本篇文章主要介紹了原生JS實(shí)現(xiàn)九宮格抽獎(jiǎng)效果的示例代碼。具有很好的參考價(jià)值。下面跟著小編一起來(lái)看下吧2017-04-04
基于微信小程序?qū)崿F(xiàn)人臉數(shù)量檢測(cè)的開(kāi)發(fā)步驟
最近項(xiàng)目需求是統(tǒng)計(jì)當(dāng)前攝像頭中的人臉個(gè)數(shù),所以下面這篇文章主要給大家介紹了關(guān)于基于微信小程序?qū)崿F(xiàn)人臉數(shù)量檢測(cè)的相關(guān)資料,文中通過(guò)圖文介紹的非常詳細(xì),需要的朋友可以參考下2022-12-12
layui默認(rèn)選中table的CheckBox復(fù)選框方法
今天小編就為大家分享一篇layui默認(rèn)選中table的CheckBox復(fù)選框方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2019-09-09
ClearTimeout消除閃動(dòng)實(shí)例代碼
本文給大家介紹ClearTimeout消除閃動(dòng)相關(guān)知識(shí),本文介紹的非常詳細(xì),具有參考借鑒價(jià)值,感興趣的朋友一起學(xué)習(xí)吧2016-02-02
javascript實(shí)現(xiàn)表格排序 編輯 拖拽 縮放
這篇文章主要介紹了javascript實(shí)現(xiàn)表格排序 編輯 拖拽 縮放的方法,效果非常不錯(cuò),只是兼容性還有些問(wèn)題,有待優(yōu)化。2015-01-01
為網(wǎng)站代碼塊pre標(biāo)簽增加一個(gè)復(fù)制代碼按鈕代碼
寫(xiě)文章的時(shí)候,作為一名專業(yè)的碼農(nóng),經(jīng)常會(huì)在文章中粘貼一些代碼。有的時(shí)候代碼塊比較長(zhǎng),在后期使用中需要復(fù)制這段代碼就比較麻煩2021-11-11
js實(shí)現(xiàn)三級(jí)聯(lián)動(dòng)效果(簡(jiǎn)單易懂)
本文主要介紹了js實(shí)現(xiàn)三級(jí)聯(lián)動(dòng)效果的示例代碼,簡(jiǎn)單易懂。具有很好的參考價(jià)值。下面跟著小編一起來(lái)看下吧2017-03-03
原生JavaScript實(shí)現(xiàn)的簡(jiǎn)單放大鏡效果示例
這篇文章主要介紹了原生JavaScript實(shí)現(xiàn)的簡(jiǎn)單放大鏡效果,涉及javascript事件響應(yīng)及頁(yè)面元素屬性動(dòng)態(tài)操作相關(guān)實(shí)現(xiàn)技巧,需要的朋友可以參考下2018-02-02

