帶你實現一個簡單的多邊形編輯器

語言: CN / TW / HK

開頭

多邊形編輯器少數見於一些圖片標註需求,常見於地圖應用,用來繪製區域,比如高德地圖:

示例地址:lbs.amap.com/api/jsapi-v…。請先試用一下,接下來實現它的所有功能。

基本準備

準備一個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')
}
複製代碼

添加頂點

創建一個多邊形的基本操作是鼠標點擊並添加頂點,所以需要監聽點擊事件,然後用線把點擊的點都連接起來,鼠標點擊事件對象的clientXclientY是相對於瀏覽器窗口的,所以需要減去畫布和瀏覽器窗口的偏移量來得到相對於畫布的座標:

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()
}
複製代碼

每次點擊都需要調用這個方法重新繪製,效果如下:

但是這樣還不是我們要的,我們想要一個從始至終都是閉合的區域,這很簡單,把首尾兩個點連起來就好了,但是這樣不會跟着鼠標當前的位置變化,所以需要把鼠標當前的位置也作為一個頂點追加進去,不過在沒有點擊前它都只是一個臨時的點,把它放進pointsArr不合適,我們用一個新變量來存儲它。

監聽鼠標移動事件來存儲當前位置:

<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事件觸發的時候也同時會觸發兩次click事件,這樣就導致最後雙擊的位置也被添加進去了,而且添加了兩次,可以手動把最後兩個點去掉或者自己使用click事件來模擬雙擊事件,本文方便起見就不處理了。

拖動頂點

多邊形閉合後,允許拖動各個頂點來修改位置,為了直觀,像高德的示例一樣給每個頂點都繪製一個圓形:

render() {
    // ...
    // 繪製頂點的圓形
    if (this.isClosePath) {
        this.ctx.save()// 因為要重新設置繪圖樣式,為了不影響線段,所以需要保存一下繪圖狀態
        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()// 恢復繪圖狀態
    }
}
複製代碼

要拖動首先要知道當前鼠標在哪個頂點內,可以在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
        // 遍歷頂點繪製圓形路徑,和上面的繪製頂點圓形的區別是這裏不需要實際描邊和填充,只需要路徑
        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()
    }
    // ...
}
複製代碼

效果如下:

拖動整體

高德的示例並沒有拖動整體的功能,但是不影響我們支持,整體拖動的邏輯和拖動單個頂點差不多,先判斷鼠標按下時是否在多邊形內,然後在移動過程中更新所有頂點的位置,和拖動單個的區別是記錄和應用的是移動的偏移量,這就需要先緩存一下鼠標按下的位置和此刻的頂點數據。

檢測是否在多邊形內:

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))
}
複製代碼

效果如下:

除了在拖動的時候吸附,添加頂點的時候也可以添加吸附功能,這裏就不做了。另外除了吸附到頂點,還需要吸附到線段,也就是線段上離當前點最近的一個點上,也以拖動單個頂點為例來看一下。

首先需要根據頂點創建一下線段:

createLineSegment () {
    let result = []
    // 創建線段
    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
}
複製代碼

創建線段最好把兩個端點相同的線段過濾掉,然後在checkAdsorbent方法添加吸附線段的邏輯,注意要添加到吸附到頂點的代碼之前,這樣會優先吸附到頂點。

checkAdsorbent (x, y) {
    let result = [x, y]
    // 吸附到線段
    let segments = this.createLineSegment()// ++
    // 吸附到頂點
    // ...
}
複製代碼

有了線段就可以遍歷線段計算和當前點距離最近的線段,使用點到直線的距離公式:

標準的直線方程為:Ax+By+C=0,有三個未知變量,我們只有兩個點,顯然計算不出三個變量,所以我們使用斜截式:y=kx+b,即不垂直於x軸的直線,計算出kb,這樣:Ax+By+C = kx-y+b = 0,得出A = kB = -1C = b,這樣只要計算出AC即可:

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=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=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
// 最後這兩條線相交的點即為距離最近的點,也就是聯立這兩個直線方程,求出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()// 創建線段
    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]
        }
    }
    // 吸附到頂點
    // ...
}
複製代碼

效果如下:

刪除及新增頂點

高德的多邊形編輯器在沒有拖動的時候會在每條線段的中心都顯示一個實心的小圓點,你不點它它曇花一現,當你去拖動它時它就會變成真實的頂點,也就完成了頂點的新增。

首先在非拖動的情況下插入虛擬頂點並渲染,然後拖動前再把它去掉,因為加入了虛擬頂點,所以在計算dragPointIndex時需要轉換成沒有虛擬頂點的真實索引,當檢測到拖動的是虛擬節點時把它轉換成真實頂點就可以了。

先插入虛擬頂點,給頂點增加一個fictitious字段來代表是否是虛擬頂點:

render () {
    // 先去掉之前插入的虛擬頂點
    this.pointsArr = this.pointsArr.filter((item) => {
        return !item.fictitious
    })
    if (this.isClosePath && !this.isMousedown) {// 插入虛擬頂點
        this.insertFictitiousPoints()
    }
    // ...
    // 先清除畫布
}
複製代碼

插入虛擬頂點就是在每兩個頂點之間插入這兩個頂點的中點座標,這個很簡單,就不附代碼了,另外,繪製頂點的時候如果是虛擬頂點,那麼把描邊顏色和填充顏色反一下,用來作區分,效果如下:

接下來修改一下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狀態給丟了
        x: item.x + diffX,
        y: item.y + diffY
    }
})
複製代碼

刪除頂點的話很容易,直接從數組裏移除即可,詳見源碼。

支持多個多邊形並存

以上只是完成了一個多邊形的創建和編輯,如果需要同時存在多個多邊形,每個都可以選中進行編輯,那麼上面的代碼是無法實現的,需要調整代碼組織方式,每個多邊形都要維護各自的狀態,那麼可以創建一個多邊形的類,把上面的一些狀態和方法都移到這個類裏,然後選中那個就操作哪個類即可。

結尾

示例代碼源碼在:github.com/wanglin2/Po…

另外還寫了一個稍微完善的版本,可以直接使用:github.com/wanglin2/ma…

感謝閲讀,再會~