个人项目开源工具----JS实现图纸标点功能

0 阅读4分钟

开发背景

在之前的日常开发过程中,遇到了很多在图片上标注范围或者点位的需求,但是搜索到一大圈之后,发现市面上并没有相关的一些插件(🐶也许是我没找到),最后动手实现了一个,目前已经开源出来,有需要的可以直接拿来用,也欢迎star

开发思路

利用canvas实现底部图片的加载,然后使用组件化的思想将Point点位和Range范围当做组件去加载到图片上去,除此之外还要考虑到一些鼠标事件,例如:点击,拖拽,以及滚轮放大缩小事件,废话不多说,核心代码如下:

export class SpaceMaker {
    constructor(parentElem, options) {
        this.parentElem = parentElem

        this.resizeDpiRatio()
        this.initialize(options)
        this.winResize()

    }
    // 动态调整dpi
    resizeDpiRatio() {
        if (window.devicePixelRatio < 2) {
            this.dpiRatio = 2
        } else {
            this.dpiRatio = window.devicePixelRatio
        }
    }

    // 初始化配置
    initialize(options) {
        if (!this.parentElem) {
            throw new Error('A DOM element is required for SpaceMaker to initialize')
        }
        this.options = options

        this.width = this.parentElem.clientWidth
        this.height = this.parentElem.clientHeight

        if (this.map) this.map.remove()
        
        this.map = createMap(this.width, this.height, this.dpiRatio)
        
        this.ctx = this.map.getContext('2d', { willReadFrequently: true })
        
        this.ctx.scale(this.dpiRatio, this.dpiRatio)

        this.parentElem.append(this.map)

        this.scaleX = _.get(this.options, 'scaleX')
        this.scaleY = _.get(this.options, 'scaleY')
        this.translateImgTo = _.get(this.options, 'translateImgTo')

        this.parseOptions(options)

        // 事件
        this.map.onmousemove = this.onMouseMove
        this.map.onmousedown = this.onMouseDown
        this.map.onmouseup = this.onMouseUp
        this.map.onmouseleave = this.onMouseLeave
        this.map.onwheel = this.onWheel

    }

    parseOptions(options) {
        this.ctx.clearRect(0, 0, this.width, this.height)
        this.marks = []
        this.ranges = []
        this.mapImgRect = {
            x: 0,
            y: 0,
            width: _.get(options, 'mapWidth') || 0,
            height: _.get(options, 'mapHeight') || 0
        }
        this.loadMapImg(_.get(options, 'mapImgUrl'), () => {
            this.onComponentsLoad(options)
        })
    }

    loadMapImg(src, cb = null) {
        if (!src) {
            throw new Error('src of picture is not found')
        }
        this.mapImg = new Image()
        this.mapImg.crossOrigin = 'anonymous'
        this.mapImg.src = src

        this.mapImg.onload = () => {
            this.mapImgRect = this.coverCenter(this.width, this.height, this.mapImg.width, this.mapImg.height)
            if (cb) {
                cb()
            }
        }
    }

然后看一下coverCenter做了哪些事情,主要就是先判断纵向比,然后返回调整之后的坐标和宽高度,重新放在画布上

    coverCenter(canvasWidth, canvasHeight, imgWidth, imgHeight) {
        let x = 0;
        let y = 0;
        let width = imgWidth;
        let height = imgHeight;

        if ((imgWidth / imgHeight) > (canvasWidth / canvasHeight)) {
            this.ratio = (canvasWidth / width);
            y = -(canvasHeight / this.ratio - imgHeight) / 2;
            height = canvasHeight / this.ratio
            x = -(canvasWidth / this.ratio - imgWidth) / 2
        } else {
            this.ratio = (canvasHeight / height);
            x = -(canvasWidth / this.ratio - imgWidth) / 2
            width = canvasWidth / this.ratio
            y = -(canvasHeight / this.ratio - imgHeight) / 2
        }

        return {
            x: x,
            y: y,
            width: width,
            height: height
        };
    }

再看onComponentsLoad初始化Point和Range组件

    onComponentsLoad(options) {
        // 从外界传进来的点位
        const _markers = _.get(options, 'marks') || []
        if (!_.isEmpty(_markers)) {
            this.marks = _markers.map(_m => {
                return new Mark(_m, this)
            })
        }
        // 从外界传进来的范围
        const _ranges = _.get(options, 'ranges') || []
        if (!_.isEmpty(_ranges)) {
            this.ranges = _ranges.map(_r => {
                return new Range(_r, this)
            })
        }
        this.drawMapImage()
        // 抛出生命周期方法供外界使用
        this.dispatch('loaded', this)

    }

drawMapImage方法最终把渲染的图片放在画布上

    drawMapImage() {
        if (!this.mapImg || !this.mapImgRect || !this.ctx) return
        this.ctx.clearRect(0, 0, this.width, this.height)

        this.ctx.drawImage(this.mapImg, this.mapImgRect.x, this.mapImgRect.y, this.mapImgRect.width,
            this.mapImgRect.height, 0, 0, this.width, this.height)

        this.marks.forEach(m => {
            m.draw()
        })

        this.ranges.forEach(r => {
            r.draw()
        })

        this.dispatch('rendered', this)
    }

再看Point组件和Range组件

  export class Mark {
    constructor(options, spaceMaker) {
        this.id = s8()
        this.options = options
        this.spaceMaker = spaceMaker

        if(_.get(this.options, 'w')) {
            this.options.center.x = (this.options.center.x * this.spaceMaker.mapImg.width) / _.get(this.options, 'w', 1)
            this.options.center.y = (this.options.center.y * this.spaceMaker.mapImg.height) / _.get(this.options,  'h', 1)
        }
        this.initOptions()
        this.parseIcon()
        
    }
    initOptions() {
        // 图标大小
        this.radius = _.get(this.options, 'radius')
        // 图标内padding
        this.padding = _.get(this.options, 'padding')
        // 图片url
        this.icon = _.get(this.options, 'icon')
        // 背景颜色
        this.bgColor = _.get(this.options, 'bgColor')
        // 是否高亮
        this.highlight = _.get(this.options, 'highlight')
        // 高亮范围大小
        this.highlightSize = _.get(this.options, 'highlightSize')
        // 额外信息
        this.ext = _.get(this.options, 'ext')
    }
    parseIcon() {
        if(this.icon.startsWith('http')) {
            this.loadImg(this.icon)
        } else if (resources[`${this.icon}`]) {
            this.loadImg(resources[`${this.icon}`])
        }
    }
    loadImg(src) {
        if (!src) {
            return
        }
        this.iconImg = new Image()
        this.iconImg.crossOrigin = 'anonymous'
        this.iconImg.src = src
        this.iconImg.onload = () => {
            this.draw()
        }
    }

    draw() {
        this.calcCenter()
        // 超出屏幕范围的不画出来
        if (this.center.x < 0 || this.center.y < 0 || this.center.x > this.spaceMaker.map.width || this.center.y > this.spaceMaker.map.height) return
        let ctx = this.spaceMaker.ctx
        ctx.save()
        if(this.highlight) {
            ctx.beginPath()
            ctx.arc(this.center.x, this.center.y, this.radius + this.highlightSize, 0, 2 * Math.PI, false)
            ctx.fillStyle = Color('rgba(9, 140, 255, 0.8)').alpha(0.4).lighten(0.5)
            ctx.fill()

            ctx.strokeStyle = Color('rgba(9, 140, 255, 0.8)').alpha(0.8).lighten(0.2)
            ctx.lineWidth = 1
            ctx.stroke()
        }
        if (this.iconImg) {
            ctx.beginPath()
            ctx.arc(this.center.x, this.center.y, this.radius, 0, 2 * Math.PI, false)
            ctx.fillStyle = this.bgColor
            ctx.fill()
            ctx.drawImage(this.iconImg, 0, 0, this.iconImg.width, this.iconImg.height, this.centerRect.x, this.centerRect.y, this.centerRect.width, this.centerRect.height)
        }
        // if (this.iconFont) {
        //     // 因为字体图标是镂空的,所以先画一个白底
        //     ctx.beginPath()
        //     if(this.icon.startsWith('D_')) {
        //         ctx.arc(this.center.x, this.center.y, this.radius, 0, 2 * Math.PI, false)
        //         ctx.fillStyle = Color(this.bgColor).alpha(0.8)
        //         ctx.font = `${this.borderRect.width - this.padding / 2}px anticon`
        //     } else {
        //         ctx.arc(this.center.x, this.center.y, this.radius - 0.2, 0, 2 * Math.PI, false)
        //         ctx.fillStyle = 'rgba(255, 255, 255, 1)'
        //         ctx.font = `${this.borderRect.width}px anticon`
        //     }
        //     ctx.fill()
        //     ctx.textAlign = 'center'
        //     ctx.textBaseline = 'middle'
        //     ctx.fillText(this.iconFont, this.center.x, this.center.y)
        // }
        ctx.restore()
    }
    calcCenter() {
        const cx = (_.get(this.options, 'center.x') - this.spaceMaker.mapImgRect.x) * this.spaceMaker.ratio
        const cy = (_.get(this.options, 'center.y') - this.spaceMaker.mapImgRect.y) * this.spaceMaker.ratio
        this.center = new Point(cx, cy)
        const innerWidth = this.radius - this.padding
        const innerHeight = this.radius - this.padding
        this.centerRect = new Rect(this.center.x - innerWidth, this.center.y - innerHeight, innerWidth * 2, innerHeight * 2)
        this.borderRect = new Rect(this.center.x - this.radius, this.center.y - this.radius, this.radius * 2, this.radius * 2)
    }
    
}

Range组件

export class Range {
    constructor(options, spaceMaker) {
        this.id = s8()
        this.options = options
        this.spaceMaker = spaceMaker
        this.initOptions()
        this.calcPoints()
        this.calcBorderRect()
    }
    initOptions() {
        this.bgColor = _.get(this.options, 'bgColor', 'rgba(247, 159, 58, 0.6)');
        this.hoverColor = _.get(this.options, 'hoverColor', 'rgba(247, 159, 58, 0.6)')
        this.ext = _.get(this.options, 'ext');
    }
    calcPoints() {
        const _points = _.get(this.options, 'points') || []
        this.points = _points.map(item => {
            const cx = (item[0] - this.spaceMaker.mapImgRect.x) * this.spaceMaker.ratio;
            const cy = (item[1] - this.spaceMaker.mapImgRect.y) * this.spaceMaker.ratio;
            return [cx, cy]
        })
    }
    calcBorderRect() {
        const xs = this.points.map(m => m[0])
        const ys = this.points.map(m => m[1])
        const minX = _.min(xs)
        const maxX = _.max(xs)
        const minY = _.min(ys)
        const maxY = _.max(ys)

        this.borderRect = new Rect(minX, minY, maxX - minX, maxY - minY)
    }
    draw() {
        const ctx = this.spaceMaker.ctx
        ctx.save()
        ctx.beginPath()
        ctx.moveTo(this.points[0][0], this.points[0][1])

        for (let i = 1; i < this.points.length; i++) {
            ctx.lineTo(this.points[i][0], this.points[i][1])
        }
        ctx.closePath();

        ctx.fillStyle = this.bgColor;
        if (this.isActive) {
            ctx.fillStyle = this.hoverColor;
            ctx.strokeStyle = Color(this.hoverColor).alpha(0.8).lighten(0.2)
            ctx.lineWidth = 2;
            ctx.stroke();
        }
        ctx.fill();
        ctx.restore();
    }
    
    hit(pos) {
        let px = pos.x, py = pos.y, flag = false;
        let poly = this.points.map(m => new Point(m[0], m[1]))

        for (let i = 0, l = poly.length, j = l - 1; i < l; j = i, i++) {
            let sx = poly[i].x,
                sy = poly[i].y,
                tx = poly[j].x,
                ty = poly[j].y

            // 点与多边形顶点重合
            if ((sx === px && sy === py) || (tx === px && ty === py)) {
                return true
            }

            // 判断线段两端点是否在射线两侧
            if ((sy < py && ty >= py) || (sy >= py && ty < py)) {
                // 线段上与射线 Y 坐标相同的点的 X 坐标
                let x = sx + (py - sy) * (tx - sx) / (ty - sy)

                // 点在多边形的边上
                if (x === px) {
                    return true
                }

                // 射线穿过多边形的边界
                if (x > px) {
                    flag = !flag
                }
            }
        }

        // 射线穿过多边形边界的次数为奇数时点在多边形内
        return flag
    }
    setActive(isActive) {
        this.isActive = isActive;
        this.spaceMaker.drawMapImage()
    }
}

再看看在项目中如何使用

<script setup>
    const drawRef = ref(null)
    const drawCanvas = ref(null)
    drawCanvas.value = new SpaceMaker(drawRef.value, {
       marks: [
         {
           center: {
             x: 500,
             y: 550
           },
           padding: 0,
           radius: 12,
           highlight: false,
           icon: 'device_normal', // 我这里使用的是内置的资源,可以用网络地址的图片
           ext: null
         }
       ],
   ranges: [
     {
         bgColor: #000,
         points: [], // 二维数组
         ext: {}
     },
   mapImgUrl: 图片地址
})
</script>
<template>
   <div>
     <div ref="drawRef" class="space"></div>
   </div>
</template>
<style lang="scss" scoped>
   .space {
     width: 100%;
     height: 100vh;
   }
</style>

实现的效果是这样的, 范围可以自定义title

image.png

点位效果

image.png

好了 分享就到此结束了,github地址在这里,觉得可以的话 请给个Star⭐️,谢谢~

github地址:github.com/qfengzzZ/sp…