開頭
多邊形編輯器少數見于一些圖片標注需求结窘,常見于地圖應用,用來繪制區(qū)域,比如高德地圖:
示例地址:https://lbs.amap.com/api/jsapi-v2/example/overlay-editor/polygon-editor-avoidpolygon朗恳。請先試用一下,接下來實現(xiàn)它的所有功能载绿。
基本準備
準備一個canvas
元素粥诫,設置一下畫布寬高姐呐,獲取一下繪圖上下文:
<div class="container" ref="container">
<canvas ref="canvas"></canvas>
</div>
init () {
let { width, height } = this.$refs.container.getBoundingClientRect()
this.width = width
this.height = height
let canvas = this.$refs.canvas
canvas.width = width
canvas.height = height
this.ctx = canvas.getContext('2d')
}
添加頂點
創(chuàng)建一個多邊形的基本操作是鼠標點擊并添加頂點早芭,所以需要監(jiān)聽點擊事件,然后用線把點擊的點都連接起來搀别,鼠標點擊事件對象的clientX
好clientY
是相對于瀏覽器窗口的怕享,所以需要減去畫布和瀏覽器窗口的偏移量來得到相對于畫布的坐標:
toCanvasPos (e) {
let { left, top } = this.$refs.canvas.getBoundingClientRect()
return {
x: e.clientX - left,
y: e.clientY - top
}
}
接下來綁定單擊事件:
<canvas ref="canvas" @click="onClick"></canvas>
然后使用一個數組來保存我們每次單擊新增的頂點:
export default {
data() {
pointsArr: []
},
methods: {
onClick (e) {
let { x, y } = this.toCanvasPos(e)
this.pointsArr.push({
x,
y
})
}
}
}
頂點有了执赡,我們遍歷它連線畫出來就行了:
render () {
// 先清除畫布
this.ctx.clearRect(0, 0, this.width, this.height)
// 頂點連線
this.ctx.beginPath()
this.pointsArr.forEach((item, index) => {
if (index === 0) {
this.ctx.moveTo(item.x, item.y)
} else {
this.ctx.lineTo(item.x, item.y)
}
})
this.ctx.lineWidth = 5
this.ctx.strokeStyle = '#38a4ec'
this.ctx.lineJoin = 'round'// 線段連接處圓滑一點更好看
this.ctx.stroke()
}
每次點擊都需要調用這個方法重新繪制,效果如下:
但是這樣還不是我們要的函筋,我們想要一個從始至終都是閉合的區(qū)域沙合,這很簡單,把首尾兩個點連起來就好了跌帐,但是這樣不會跟著鼠標當前的位置變化首懈,所以需要把鼠標當前的位置也作為一個頂點追加進去,不過在沒有點擊前它都只是一個臨時的點谨敛,把它放進pointsArr
不合適究履,我們用一個新變量來存儲它。
監(jiān)聽鼠標移動事件來存儲當前位置:
<canvas ref="canvas" @click="onClick" @mousemove="onMousemove"></canvas>
export default {
data () {
return {
// ...
tmpPoint: null
}
},
methods: {
onMousemove (e) {
let { x, y } = this.toCanvasPos(e)
if (this.tmpPoint) {
this.tmpPoint.x = x
this.tmpPoint.y = y
} else {
this.tmpPoint = {
x,
y
}
}
this.render()// 鼠標移動時不斷刷新重繪
}
}
}
接下來連線的時候加上這個點脸狸,另外也設置一下填充樣式:
render () {
this.ctx.clearRect(0, 0, this.width, this.height)
this.ctx.beginPath()
let pointsArr = this.pointsArr.concat(this.tmpPoint ? [this.tmpPoint] : [])// ++ 把鼠標當前位置追加到最后
pointsArr.forEach((item, index) => {
if (index === 0) {
this.ctx.moveTo(item.x, item.y)
} else {
this.ctx.lineTo(item.x, item.y)
}
})
this.ctx.closePath()// ++ 閉合路徑
this.ctx.lineWidth = 5
this.ctx.strokeStyle = '#38a4ec'
this.ctx.lineJoin = 'round'
this.ctx.fillStyle = 'rgba(0, 136, 255, 0.3)'// ++
this.ctx.fill()// ++
this.ctx.stroke()
}
效果如下:
最后添加一下雙擊事件來完成頂點的添加:
<canvas ref="canvas" @click="onClick" @mousemove="onMousemove" @dblclick="onDbClick"></canvas>
{
onDbClick () {
this.isClosePath = true// 添加一個變量來標志是否閉合形狀
this.tmpPoint = null// 清空臨時點
this.render()
},
onClick (e) {
if (this.isClosePath) {
return
}
// ...
},
onMousemove (e) {
if (this.isClosePath) {
return
}
// ...
}
}
需要注意的是dbClick
事件觸發(fā)的時候也同時會觸發(fā)兩次click
事件最仑,這樣就導致最后雙擊的位置也被添加進去了,而且添加了兩次炊甲,可以手動把最后兩個點去掉或者自己使用click
事件來模擬雙擊事件泥彤,本文方便起見就不處理了。
拖動頂點
多邊形閉合后卿啡,允許拖動各個頂點來修改位置全景,為了直觀,像高德的示例一樣給每個頂點都繪制一個圓形:
render() {
// ...
// 繪制頂點的圓形
if (this.isClosePath) {
this.ctx.save()// 因為要重新設置繪圖樣式牵囤,為了不影響線段爸黄,所以需要保存一下繪圖狀態(tài)
this.ctx.lineWidth = 2
this.ctx.strokeStyle = '#1791fc'
this.ctx.fillStyle = '#fff'
this.pointsArr.forEach((item, index) => {
this.ctx.beginPath()
this.ctx.arc(item.x, item.y, 6, 0, 2 * Math.PI)
this.ctx.fill()
this.ctx.stroke()
})
this.ctx.restore()// 恢復繪圖狀態(tài)
}
}
要拖動首先要知道當前鼠標在哪個頂點內,可以在mousedown
事件里使用isPointPath
方法來檢測:
<canvas
ref="canvas"
@click="onClick"
@mousemove="onMousemove"
@dblclick="onDbClick"
@mousedown="onMousedown"
></canvas>
export default {
onMousedown (e) {
if (!this.isClosePath) {
return
}
this.isMousedown = true
let { x, y } = this.toCanvasPos(e)
this.dragPointIndex = this.checkPointIndex(x, y)
},
// 檢測是否在某個頂點內
checkPointIndex (x, y) {
let result = -1
// 遍歷頂點繪制圓形路徑揭鳞,和上面的繪制頂點圓形的區(qū)別是這里不需要實際描邊和填充炕贵,只需要路徑
this.pointsArr.forEach((item, index) => {
this.ctx.beginPath()
this.ctx.arc(item.x, item.y, 6, 0, 2 * Math.PI)
// 檢測是否在當前路徑內
if (this.ctx.isPointInPath(x, y)) {
result = index
}
})
return result
}
}
知道當前拖動的是哪個頂點后就可以在mousemove
事件里面實時更新該頂點的位置了:
onMousemove (e) {
// 實時更新當前拖動的頂點位置
if (this.isClosePath && this.isMousedown && this.dragPointIndex !== -1) {
let { x, y } = this.toCanvasPos(e)
// 刪除原來的點插入新的點
this.pointsArr.splice(this.dragPointIndex, 1, {
x,
y
})
this.render()
}
// ...
}
效果如下:
拖動整體
高德的示例并沒有拖動整體的功能,但是不影響我們支持野崇,整體拖動的邏輯和拖動單個頂點差不多称开,先判斷鼠標按下時是否在多邊形內,然后在移動過程中更新所有頂點的位置,和拖動單個的區(qū)別是記錄和應用的是移動的偏移量鳖轰,這就需要先緩存一下鼠標按下的位置和此刻的頂點數據清酥。
檢測是否在多邊形內:
export default{
onMousedown (e) {
// ...
// 記錄按下的起始位置
this.startPos.x = x
this.startPos.y = y
// 記錄當前頂點數據
this.cachePointsArr = this.pointsArr.map((item) => {
return {
...item
}
})
this.isInPolygon = this.checkInPolygon(x, y)
},
// 檢查是否在多邊形內
checkInPolygon (x, y) {
// 繪制并閉合路徑,不實際描邊
this.ctx.beginPath()
this.pointsArr.forEach((item, index) => {
if (index === 0) {
this.ctx.moveTo(item.x, item.y)
} else {
this.ctx.lineTo(item.x, item.y)
}
})
this.ctx.closePath()
return this.ctx.isPointInPath(x, y)
}
}
更新所有頂點位置:
onMousemove (e) {
// 更新所有頂點位置
if (this.isClosePath && this.isMousedown && this.dragPointIndex === -1 && this.isInPolygon) {
let diffX = x - this.startPos.x
let diffY = y - this.startPos.y
this.pointsArr = this.cachePointsArr.map((item) => {
return {
x: item.x + diffX,
y: item.y + diffY
}
})
this.render()
}
// ...
}
效果如下:
吸附功能
吸附功能能提升使用體驗蕴侣,首先吸附到頂點是比較簡單的焰轻,遍歷一下所有頂點,計算與當前頂點的距離昆雀,小于某個值就把當前頂點的位置突變過去就可以了辱志。
以拖動更新單個頂點的位置時添加一下吸附判斷:
onMousemove (e) {
if (this.isClosePath && this.isMousedown && this.dragPointIndex !== -1) {
let { x, y } = this.toCanvasPos(e)
let adsorbentPos = this.checkAdsorbent(x, y)// ++ 判斷是否需要進行吸附
this.pointsArr.splice(this.dragPointIndex, 1, {
x: adsorbentPos[0],// ++ 修改為吸附的值
y: adsorbentPos[1]// ++ 修改為吸附的值
})
this.render()
}
// ...
}
判斷吸附的方法:
checkAdsorbent (x, y) {
let result = [x, y]
// 吸附到頂點
let minDistance = Infinity
this.pointsArr.forEach((item, index) => {
// 跳過和自身的比較
if (this.dragPointIndex === index) {
return
}
// 獲取兩點距離
let distance = this.getTwoPointDistance(item.x, item.y, x, y)
// 如果小于10的話則使用該頂點的位置來替換當前鼠標的位置
if (distance <= 10 && distance < minDistance) {
minDistance = distance
result = [item.x, item.y]
}
})
return result
}
getTwoPointDistance
方法用來計算兩個點的距離:
getTwoPointDistance (x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2))
}
效果如下:
除了在拖動的時候吸附,添加頂點的時候也可以添加吸附功能狞膘,這里就不做了揩懒。另外除了吸附到頂點,還需要吸附到線段挽封,也就是線段上離當前點最近的一個點上已球,也以拖動單個頂點為例來看一下。
首先需要根據頂點創(chuàng)建一下線段:
createLineSegment () {
let result = []
// 創(chuàng)建線段
let arr = this.pointsArr
let len = arr.length
for (let i = 0; i < len - 1; i++) {
result.push([
arr[i],
arr[i + 1]
])
}
// 加上起點和終點組成的線段
result.push([
arr[len - 1],
arr[0]
])
// 去掉包含當前拖動點的線段
if (this.dragPointIndex !== -1) {
// 如果拖動的是起點辅愿,那么去掉第一條和最后一條線段
if (this.dragPointIndex === 0) {
result.splice(0, 1)
result.splice(-1, 1)
} else { // 其余中間的點則去掉前一根和后一根
result.splice(this.dragPointIndex - 1, 2)
}
}
return result
}
創(chuàng)建線段最好把兩個端點相同的線段過濾掉智亮,然后在checkAdsorbent
方法添加吸附線段的邏輯,注意要添加到吸附到頂點的代碼之前渠缕,這樣會優(yōu)先吸附到頂點。
checkAdsorbent (x, y) {
let result = [x, y]
// 吸附到線段
let segments = this.createLineSegment()// ++
// 吸附到頂點
// ...
}
有了線段就可以遍歷線段計算和當前點距離最近的線段褒繁,使用點到直線的距離公式:
標準的直線方程為:Ax+By+C=0
亦鳞,有三個未知變量,我們只有兩個點棒坏,顯然計算不出三個變量燕差,所以我們使用斜截式:y=kx+b
,即不垂直于x軸的直線坝冕,計算出k
和b
徒探,這樣:Ax+By+C = kx-y+b = 0
,得出A = k
喂窟,B = -1
测暗,C = b
,這樣只要計算出A
和C
即可:
getLinePointDistance (x1, y1, x2, y2, x, y) {
// 垂直于x軸的直線特殊處理磨澡,橫坐標相減就是距離
if (x1 === x2) {
return Math.abs(x - x1)
} else {
let B = -1
let A, C
A = (y2 - y1) / (x2 - x1)
C = 0 - B * y1 - A * x1
return Math.abs((A * x + B * y + C) / Math.sqrt(A * A + B * B))
}
}
知道最近的線段之后問題又來了碗啄,得知道線段上離該點最近的一個點,假設線段s
的兩個端點為:(x1,y1)
稳摄、(x2,y2)
稚字,點p
為:(x0,y0)
,那么有如下推導:
// 線段s的斜率
let k = (y2 - y1) / (x2 - x1)
// 端點1代入斜截式公式y(tǒng)=kx+b
let y1 = k * x1 + b
// 得出b
let b = y1 - k * x1
// k和b都知道了,直線公式也就知道了
let y = k * x + b = k * x + y1 - k * x1 = k * (x - x1) + y1
// 線段上離點p最近的點和p組成的直線一定是垂直于線段s的胆描,即垂線瘫想,垂線的斜率k1和線段的斜率k乘積為-1,那么
let k1 = -1 / k
// 點p代入斜截式公式y(tǒng)=kx+b昌讲,求出垂線的直線方程
let y0 = k1 * x0 + b
let b = y0 - k1 * x0
let y = k1 * x + y0 - k1 * x0 = k1 * (x - x0) + y0 = (-1 / k) * (x - x0) + y0
// 最后這兩條線相交的點即為距離最近的點国夜,也就是聯(lián)立這兩個直線方程,求出x和y
let y = k * (x - x1) + y1
let x = (k * k * x1 + k * (y0 - y1) + x0) / (k * k + 1)
根據以上推導剧蚣,可以計算出最近的點支竹,不過最后還需要判斷一下這個點是否在線段上,也許是在直線的其他位置:
getNearestPoint (x1, y1, x2, y2, x0, y0) {
let k = (y2 - y1) / (x2 - x1)
let x = (k * k * x1 + k * (y0 - y1) + x0) / (k * k + 1)
let y = k * (x - x1) + y1
// 判斷該點的x坐標是否在線段的兩個端點之間
let min = Math.min(x1, x2)
let max = Math.max(x1, x2)
// 如果在線段內就是我們要的點
if (x >= min && x <= max) {
return {
x,
y
}
} else { // 否則返回null
return null
}
}
接下來跟吸附到頂點一樣鸠按,突變到這個位置:
checkAdsorbent (x, y) {
let result = [x, y]
// 吸附到線段
let segments = this.createLineSegment()// 創(chuàng)建線段
let nearestLineResult = this.getPintNearestLine(x, y, segments)// 找到最近的一條線段
if (nearestLineResult[0] <= 10) {// 距離小于10進行吸附
let segment = nearestLineResult[1]// 線段的兩個端點
let nearestPoint = this.getNearestPoint(segment[0].x, segment[0].y, segment[1].x, segment[1].y, x, y)// 找到線段上最近的點
if (nearestPoint) {
result = [nearestPoint.x, nearestPoint.y]
}
}
// 吸附到頂點
// ...
}
效果如下:
刪除及新增頂點
高德的多邊形編輯器在沒有拖動的時候會在每條線段的中心都顯示一個實心的小圓點礼搁,你不點它它曇花一現(xiàn),當你去拖動它時它就會變成真實的頂點目尖,也就完成了頂點的新增馒吴。
首先在非拖動的情況下插入虛擬頂點并渲染,然后拖動前再把它去掉瑟曲,因為加入了虛擬頂點饮戳,所以在計算dragPointIndex
時需要轉換成沒有虛擬頂點的真實索引,當檢測到拖動的是虛擬節(jié)點時把它轉換成真實頂點就可以了洞拨。
先插入虛擬頂點扯罐,給頂點增加一個fictitious
字段來代表是否是虛擬頂點:
render () {
// 先去掉之前插入的虛擬頂點
this.pointsArr = this.pointsArr.filter((item) => {
return !item.fictitious
})
if (this.isClosePath && !this.isMousedown) {// 插入虛擬頂點
this.insertFictitiousPoints()
}
// ...
// 先清除畫布
}
插入虛擬頂點就是在每兩個頂點之間插入這兩個頂點的中點坐標,這個很簡單烦衣,就不附代碼了歹河,另外,繪制頂點的時候如果是虛擬頂點花吟,那么把描邊顏色和填充顏色反一下秸歧,用來作區(qū)分,效果如下:
接下來修改一下mousemove
方法衅澈,如果拖動的是虛擬頂點键菱,那就把它轉換成真實頂點,也就是把fictitious
字段給刪了:
onMousemove (e) {
if (this.isClosePath && this.isMousedown && this.dragPointIndex !== -1) {
// 如果是虛擬頂點今布,轉換成真實頂點
if (this.pointsArr[this.dragPointIndex].fictitious) {
delete this.pointsArr[this.dragPointIndex].fictitious
}
// 轉換成沒有虛擬頂點時的真實索引
let prevFictitiousCount = 0
for (let i = 0; i < this.dragPointIndex; i++) {
if (this.pointsArr[i].fictitious) {
prevFictitiousCount++
}
}
this.dragPointIndex -= prevFictitiousCount
// 移除虛擬頂點
this.pointsArr = this.pointsArr.filter((item) => {
return !item.fictitious
})
// 之前的拖動邏輯...
}
// ...
}
效果如下:
最后修復一下整體拖動時的bug:
this.pointsArr = this.cachePointsArr.map((item) => {
return {
...item,// ++经备,不要把fictitious狀態(tài)給丟了
x: item.x + diffX,
y: item.y + diffY
}
})
刪除頂點的話很容易,直接從數組里移除即可部默,詳見源碼弄喘。
支持多個多邊形并存
以上只是完成了一個多邊形的創(chuàng)建和編輯,如果需要同時存在多個多邊形甩牺,每個都可以選中進行編輯蘑志,那么上面的代碼是無法實現(xiàn)的,需要調整代碼組織方式,每個多邊形都要維護各自的狀態(tài)急但,那么可以創(chuàng)建一個多邊形的類澎媒,把上面的一些狀態(tài)和方法都移到這個類里,然后選中那個就操作哪個類即可波桩。
結尾
示例代碼源碼在:https://github.com/wanglin2/PolygonEditDemo戒努。
另外還寫了一個稍微完善的版本,可以直接使用:https://github.com/wanglin2/markjs镐躲。
感謝閱讀储玫,再會~