帶你實現(xiàn)一個簡單的多邊形編輯器

開頭

多邊形編輯器少數見于一些圖片標注需求结窘,常見于地圖應用,用來繪制區(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)聽點擊事件,然后用線把點擊的點都連接起來搀别,鼠標點擊事件對象的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()
}

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

但是這樣還不是我們要的函筋,我們想要一個從始至終都是閉合的區(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軸的直線坝冕,計算出kb徒探,這樣:Ax+By+C = kx-y+b = 0,得出A = k喂窟,B = -1测暗,C = 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(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镐躲。

感謝閱讀储玫,再會~

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市萤皂,隨后出現(xiàn)的幾起案子撒穷,更是在濱河造成了極大的恐慌,老刑警劉巖裆熙,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件端礼,死亡現(xiàn)場離奇詭異,居然都是意外死亡入录,警方通過查閱死者的電腦和手機蛤奥,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來僚稿,“玉大人凡桥,你說我怎么就攤上這事∈赐” “怎么了缅刽?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長唤崭。 經常有香客問我拷恨,道長脖律,這世上最難降的妖魔是什么谢肾? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮小泉,結果婚禮上芦疏,老公的妹妹穿的比我還像新娘。我一直安慰自己微姊,他們只是感情好酸茴,可當我...
    茶點故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著兢交,像睡著了一般薪捍。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天酪穿,我揣著相機與錄音凳干,去河邊找鬼。 笑死被济,一個胖子當著我的面吹牛救赐,可吹牛的內容都是我干的。 我是一名探鬼主播只磷,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼经磅,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了钮追?” 一聲冷哼從身側響起预厌,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎畏陕,沒想到半個月后配乓,有當地人在樹林里發(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡惠毁,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年犹芹,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片鞠绰。...
    茶點故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡腰埂,死狀恐怖,靈堂內的尸體忽然破棺而出蜈膨,到底是詐尸還是另有隱情屿笼,我是刑警寧澤,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布翁巍,位于F島的核電站驴一,受9級特大地震影響,放射性物質發(fā)生泄漏灶壶。R本人自食惡果不足惜肝断,卻給世界環(huán)境...
    茶點故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望驰凛。 院中可真熱鬧胸懈,春花似錦、人聲如沸恰响。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽胚宦。三九已至首有,卻和暖如春燕垃,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背井联。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工利术, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人低矮。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓印叁,卻偏偏與公主長得像,于是被迫代替她去往敵國和親军掂。 傳聞我的和親對象是個殘疾皇子轮蜕,可洞房花燭夜當晚...
    茶點故事閱讀 42,916評論 2 344

推薦閱讀更多精彩內容