Take you to implement a simple polygon editor

start

Polygon editor is rarely used in some image annotation requirements. It is commonly used in map applications to draw areas, such as Gaode map:

Example address: https://lbs.amap.com/api/jsapi-v2/example/overlay-editor/polygon-editor-avoidpolygon . Please try it first, and then realize all its functions.

Basic preparation

Prepare a canvas element, set the canvas width and height, and obtain the drawing context:

<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')
}

add vertex

The basic operation of creating a polygon is to click and add vertices, so you need to listen to the click event, and then connect the click points with lines. The clientX and clientY of the mouse click event object are relative to the browser window, so you need to subtract the offset of the canvas and the browser window to obtain the coordinates relative to the canvas:

toCanvasPos (e) {
    let { left, top } = this.$refs.canvas.getBoundingClientRect()
    return {
        x: e.clientX - left,
        y: e.clientY - top
    }
}

Next, click bind event:

<canvas ref="canvas" @click="onClick"></canvas>

Then we use an array to save the vertices we add each time we click:

export default {
    data() {
        pointsArr: []
    },
    methods: {
        onClick (e) {
            let { x, y } = this.toCanvasPos(e)
            this.pointsArr.push({
                x,
                y
            })
        }
    }
}

When there is a vertex, let's traverse it and draw the line:

render () {
    // Clear the canvas first
    this.ctx.clearRect(0, 0, this.width, this.height)
    // Vertex line
    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'// It's better to be smooth at the connection of line segments
    this.ctx.stroke()
}

This method needs to be called to redraw each click. The effect is as follows:

But this is not what we want. We want an area that is closed from beginning to end. This is very simple. Connect the first and last points, but it will not change with the current position of the mouse. Therefore, we need to add the current position of the mouse as a vertex, but it is only a temporary point before clicking, It's not appropriate to put it in pointsArr. We use a new variable to store it.

Listen for mouse movement events to store the current position:

<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()// Refresh and redraw continuously when the mouse moves
        }
    }
}

Next, add this point when connecting, and also set the filling pattern:

render () {
    this.ctx.clearRect(0, 0, this.width, this.height)
    this.ctx.beginPath()
    let pointsArr = this.pointsArr.concat(this.tmpPoint ? [this.tmpPoint] : [])// ++Appends the current mouse position to the end
    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()// ++Closed path
    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()
}

The effects are as follows:

Finally, add a double-click event to complete the addition of vertices:

<canvas ref="canvas" @click="onClick" @mousemove="onMousemove" @dblclick="onDbClick"></canvas>
{
    onDbClick () {
        this.isClosePath = true// Add a variable to indicate whether the shape is closed
        this.tmpPoint = null// Empty temporary point
        this.render()
    },
    onClick (e) {
        if (this.isClosePath) {
            return
        }
        // ...
    },
    onMousemove (e) {
        if (this.isClosePath) {
            return
        }
        // ...
    }
}

It should be noted that when the dbClick event is triggered, the click event will be triggered twice at the same time. As a result, the last double-click position will be added and added twice. You can manually remove the last two points or use the click event to simulate the double-click event. This article will not deal with it for convenience.

Drag Vertex

After the polygon is closed, it is allowed to drag each vertex to modify the position. For intuition, draw a circle for each vertex like golde's example:

render() {
    // ...
    // Draws a circle of vertices
    if (this.isClosePath) {
        this.ctx.save()// Because you want to reset the drawing style, you need to save the drawing state in order not to affect the line segments
        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()// Restore drawing state
    }
}

To drag, you must first know which vertex the current mouse is in. You can use the isPointPath method in the mousedown event to detect:

<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)
    },
    // Detect whether it is within a vertex
    checkPointIndex (x, y) {
        let result = -1
        // The difference between traversing vertices to draw circular paths and drawing vertex circles above is that there is no need for actual tracing and filling, only path
        this.pointsArr.forEach((item, index) => {
            this.ctx.beginPath()
            this.ctx.arc(item.x, item.y, 6, 0, 2 * Math.PI)
            // Detect whether it is within the current path
            if (this.ctx.isPointInPath(x, y)) {
                result = index
            }
        })
        return result
    }
}

After you know which vertex you are currently dragging, you can update the position of the vertex in real time in the mousemove event:

onMousemove (e) {
    // Updates the current dragged vertex position in real time
    if (this.isClosePath && this.isMousedown && this.dragPointIndex !== -1) {
        let { x, y } = this.toCanvasPos(e)
        // Delete the original point and insert a new point
        this.pointsArr.splice(this.dragPointIndex, 1, {
            x,
            y
        })
        this.render()
    }
    // ...
}

The effects are as follows:

Drag whole

Gaode's example does not have the function of dragging the whole, but it does not affect our support. The logic of dragging the whole is similar to that of dragging a single vertex. First judge whether it is in the polygon when the mouse is pressed, and then update the position of all vertices during the moving process. The moving offset is recorded and applied when dragging a single area, This requires caching the position of the mouse button and the vertex data at the moment.

Detect whether it is within a polygon:

export default{
    onMousedown (e) {
        // ...
        // Record the starting position pressed
        this.startPos.x = x
        this.startPos.y = y
        // Record current vertex data
        this.cachePointsArr = this.pointsArr.map((item) => {
            return {
                ...item
            }
        })
        this.isInPolygon = this.checkInPolygon(x, y)
    },
    // Check whether it is within the polygon
    checkInPolygon (x, y) {
        // Draws and closes the path without actual stroke
        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)
    }
}

Update all vertex positions:

onMousemove (e) {
    // Update all vertex positions
    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()
    }
    // ...
}

The effects are as follows:

Adsorption function

The adsorption function can improve the use experience. First, it is relatively simple to adsorb to the vertex. Traverse all vertices, calculate the distance from the current vertex, and mutate the position of the current vertex if it is less than a certain value.

Add the following when dragging to update the position of a single vertex:

onMousemove (e) {
    if (this.isClosePath && this.isMousedown && this.dragPointIndex !== -1) {
        let { x, y } = this.toCanvasPos(e)
        let adsorbentPos = this.checkAdsorbent(x, y)// ++Determine whether adsorption is required
        this.pointsArr.splice(this.dragPointIndex, 1, {
            x: adsorbentPos[0],// ++Change to adsorbed value
            y: adsorbentPos[1]// ++Change to adsorbed value
        })
        this.render()
    }
    // ...
}

Method for judging adsorption:

checkAdsorbent (x, y) {
    let result = [x, y]
    // Adsorb to vertex
    let minDistance = Infinity
    this.pointsArr.forEach((item, index) => {
        // Skip comparison with itself
        if (this.dragPointIndex === index) {
            return
        }
        // Get two-point distance
        let distance = this.getTwoPointDistance(item.x, item.y, x, y)
        // If it is less than 10, the position of the vertex is used to replace the current mouse position
        if (distance <= 10 && distance < minDistance) {
            minDistance = distance
            result = [item.x, item.y]
        }
    })
    return result
}

The getTwoPointDistance method is used to calculate the distance between two points:

getTwoPointDistance (x1, y1, x2, y2) {
    return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2))
}

The effects are as follows:

In addition to adsorption when dragging, adsorption can also be added when adding vertices. This is not done here. In addition, in addition to adsorbing to the vertex, you also need to adsorb to the line segment, that is, the point on the line segment closest to the current point. Take dragging a single vertex as an example.

First, you need to create the following segments based on vertices:

createLineSegment () {
    let result = []
    // Create segment
    let arr = this.pointsArr
    let len = arr.length
    for (let i = 0; i < len - 1; i++) {
        result.push([
            arr[i],
            arr[i + 1]
        ])
    }
    // Add a segment consisting of a start point and an end point
    result.push([
        arr[len - 1],
        arr[0]
    ])
    // Removes the segment containing the current drag point
    if (this.dragPointIndex !== -1) {
        // If you drag the start point, the first and last segments are removed
        if (this.dragPointIndex === 0) {
            result.splice(0, 1)
            result.splice(-1, 1)
        } else { // The other middle points remove the former and the latter
            result.splice(this.dragPointIndex - 1, 2)
        }
    }
    return result
}

When creating a segment, it is best to filter out the segments with the same two endpoints, and then add the logic of adsorbing segments in the checkAdsorbent method. Note that before adding to the code adsorbed to the vertex, it will be adsorbed to the vertex first.

checkAdsorbent (x, y) {
    let result = [x, y]
    // Adsorb to segment
    let segments = this.createLineSegment()// ++
    // Adsorb to vertex
    // ...
}

With a line segment, you can traverse the line segment, calculate the line segment closest to the current point, and use the distance formula from point to line:

The standard straight-line equation is: Ax+By+C=0, there are three unknown variables, we only have two points, obviously we can't calculate three variables, so we use the oblique section formula: y=kx+b, that is, the straight line not perpendicular to the x-axis, to calculate K and b, so: Ax+By+C = kx-y+b = 0, to get A = k, B = -1, C = b, so as long as we calculate A and C:

getLinePointDistance (x1, y1, x2, y2, x, y) {
    // The straight line perpendicular to the x axis is specially treated, and the abscissa subtraction is the distance
    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))
    }
}

After knowing the nearest line segment, the problem comes again. We have to know the point closest to the point on the line segment. Assuming that the two endpoints of line segment s are: (x1,y1), (x2,y2) and point p is: (x0,y0), then the following derivation is made:

// Slope of line segment s
let k = (y2 - y1) / (x2 - x1)
// Substitution of endpoint 1 into oblique section formula y=kx+b
let y1 = k * x1 + b
// Get b
let b = y1 - k * x1
// k and b are known, so is the straight line formula
let y = k * x + b = k * x + y1 - k * x1 = k * (x - x1) + y1
// The line formed by the point closest to the point p and p on the line segment must be perpendicular to the line segment s, that is, the vertical line. The product of the slope k1 of the vertical line and the slope k of the line segment is - 1, then
let k1 = -1 / k
// Point p is substituted into the oblique section formula y=kx+b to obtain the straight line equation of the vertical line
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
// Finally, the point where the two lines intersect is the closest point, that is, the two straight-line equations are established to find x and y
let y = k * (x - x1) + y1
let x = (k * k * x1 + k * (y0 - y1) + x0) / (k * k + 1)

According to the above derivation, the nearest point can be calculated, but finally, it is necessary to judge whether the point is on the line segment, perhaps in other positions of the straight line:

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
    // Judge whether the x coordinate of the point is between the two endpoints of the line segment
    let min = Math.min(x1, x2)
    let max = Math.max(x1, x2)
    // If it is in the line segment, it is the point we want
    if (x >= min && x <= max) {
        return {
            x,
            y
        }
    } else { // Otherwise, null is returned
        return null
    }
}

Next, just like the adsorption to the vertex, it suddenly changes to this position:

checkAdsorbent (x, y) {
    let result = [x, y]
    // Adsorb to segment
    let segments = this.createLineSegment()// Create segment
    let nearestLineResult = this.getPintNearestLine(x, y, segments)// Find the nearest line segment
    if (nearestLineResult[0] <= 10) {// Adsorption at a distance of less than 10
        let segment = nearestLineResult[1]// Two endpoints of a segment
        let nearestPoint = this.getNearestPoint(segment[0].x, segment[0].y, segment[1].x, segment[1].y, x, y)// Find the nearest point on the line segment
        if (nearestPoint) {
            result = [nearestPoint.x, nearestPoint.y]
        }
    }
    // Adsorb to vertex
    // ...
}

The effects are as follows:

Delete and add vertices

Gaud's polygon editor will display a solid dot in the center of each line segment without dragging. If you don't click it, it will be a flash in the pan. When you drag it, it will become a real vertex and complete the addition of vertices.

First, insert the virtual vertex and render it without dragging, and then remove it before dragging. Because the virtual vertex is added, it needs to be converted into a real index without virtual vertex when calculating the dragPointIndex. When it is detected that the dragged virtual node is a virtual node, it can be converted into a real vertex.

Insert a virtual vertex first, and add a fictitious field to the vertex to represent whether it is a virtual vertex:

render () {
    // Remove the previously inserted virtual vertices first
    this.pointsArr = this.pointsArr.filter((item) => {
        return !item.fictitious
    })
    if (this.isClosePath && !this.isMousedown) {// Insert virtual vertex
        this.insertFictitiousPoints()
    }
    // ...
    // Clear the canvas first
}

Inserting a virtual vertex is to insert the midpoint coordinates of the two vertices between each two vertices. This is very simple and no code is attached. In addition, if the vertex is a virtual vertex, reverse the stroke color and fill color to distinguish it. The effect is as follows:

Next, modify the mousemove method. If you drag a virtual vertex, convert it to a real vertex, that is, delete the ficitious field:

onMousemove (e) {
    if (this.isClosePath && this.isMousedown && this.dragPointIndex !== -1) {
        // If it is a virtual vertex, convert it to a real vertex
        if (this.pointsArr[this.dragPointIndex].fictitious) {
            delete this.pointsArr[this.dragPointIndex].fictitious
        }
        // Convert to real index without virtual vertices
        let prevFictitiousCount = 0
        for (let i = 0; i < this.dragPointIndex; i++) {
            if (this.pointsArr[i].fictitious) {
                prevFictitiousCount++
            }
        }
        this.dragPointIndex -= prevFictitiousCount
        // Remove virtual vertices
        this.pointsArr = this.pointsArr.filter((item) => {
            return !item.fictitious
        })
        // Previous drag logic
    }
    // ...
}

The effects are as follows:

Finally, fix the bug when dragging as a whole:

this.pointsArr = this.cachePointsArr.map((item) => {
    return {
        ...item,// ++, don't lose the fictitious state
        x: item.x + diffX,
        y: item.y + diffY
    }
})

Deleting vertices is easy. You can remove them directly from the array. See the source code for details.

Support the coexistence of multiple polygons

The above only completes the creation and editing of a polygon. If multiple polygons need to exist at the same time and each polygon can be selected for editing, the above code cannot be implemented. You need to adjust the code organization mode. Each polygon needs to maintain its own state, so you can create a polygon class, Move some of the above states and methods to this class, and then select which class to operate.

ending

The source code of the sample code is: https://github.com/wanglin2/PolygonEditDemo.

In addition, a slightly improved version is written, which can be used directly: https://github.com/wanglin2/markjs.

Thanks for reading. Bye~

Added by JMJimmy on Mon, 03 Jan 2022 07:49:18 +0200