180行代码,基于D3.js手把手教你实现图片标注功能1.0

3,244 阅读7分钟

项目背景

公司最近在开发一个打标签平台,其中有个一功能模块就是对图片进行标注(例如将图片里的人、汽车进行标记),简单地来讲就是在图片加个形状(矩形、圆形。。),可以移动及大小设置。因为最近在学习D3,就想能不能基于D3来实现这个功能呢,然后就用D3实现了这个需求,下面是我测试的一个小demo:。

D3.js简介

正所谓工欲善其事,必先利其器。我们这个功能基于D3开发的,那么D3到底是个啥玩意呢,和echarts,AntV系列库一样,D3是目前最流行的数据驱动型可视化库之一。与前者的区别在于,D3并不是直接给你一套可现用的图表,而是提供一套非常友好的比较底层API,自定义程度极高,你能想到的效果都可以用D3来实现。当设计师出一份非常精美的可视化图标的话,D3可能是最好的选择。

实现思路

主要是两块:

1, 添加矩形:

鼠标按下(mousedown)得到一个坐标(x0, y0), 鼠标抬起(mouseup)的到一个坐标(x1, y1),由这两个点就可以在svg中定义一个矩形(顶点坐标(x, y), 宽width, 高height)。

具体细分为四种情况:(蓝色代表mousedown按下的点, 黄色代表mouseup的点,红圈代表矩形的顶点),有顶点,有宽高(两点坐标可以简单算出),矩形就出来了。

2, 拖拽

拖拽有两种情况:

1,拖拽矩形,只会改变矩形的位置,不会改变矩形的大小(这个简单,通过拖拽的起点和拖拽的重点即可判断)

2,拖拽矩形四个角上的圆, 不仅会改变矩形的位置,还会改变大小(把握住顶点和拖拽点就ok),这里面有一丢丢小学生的计算问题,分析清楚也是很简单地,不慌。

代码实战!

一、准备工作:一个画布,插入一张图片, 绑定事件(暂时不去实现),添加一个十字坐标(添加矩形的时候显示,提升视觉效果)

注:.attr('xxx', params) 是添加(有参数)或者读取(无参数)元素属性,语法有点像jquery

// 创建svg画布、定义宽(700)高(700)
const svg = D3.select('#container5').append('svg')
	.attr('width', this.width).attr('height', this.height)
      // 插入图片
svg.append('image').attr('class', 'control-img').attr('height', this.height).attr('width', this.width)
.attr('href', 'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1601013602408&di=a2354eb5ba74fef7511e60c47cab5d97&imgtype=0&src=http%3A%2F%2Ft8.baidu.com%2Fit%2Fu%3D3571592872%2C3353494284%26fm%3D79%26app%3D86%26f%3DJPEG%3Fw%3D1200%26h%3D1290')

svg.on('mousemove', this.mouseMove) // 鼠标在svg上移动触发
svg.on('mouseleave', this.mouseLeave) // 鼠标离开svg触发
svg.on('mousedown', this.mouseDown) // 鼠标按下触发
svg.on('mouseup', this.mouseUp) // 鼠标抬起时触发

// 创建十字坐标
const positionXY = svg.append('g').attr('class', 'line-g')
svg.append('g').attr('id', 'rect-g') //用于存放矩形的容器
positionXY.append('line').attr('id', 'line-x').attr('x1', 0).attr('y1', 0).attr('x2', 700).attr('y2', 0).attr('stroke', 'white').attr('stroke-width', 0)
positionXY.append('line').attr('id', 'line-y').attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', 700).attr('stroke', 'white').attr('stroke-width', 0)
positionXY.append('circle').attr('id', 'line-circle').attr('cx', -10).attr('cy', -10).attr('r', 5).attr('fill', 'red')

**十字坐标:**就是在添加矩形的时候出现的两条白线和一个红点,这个很简单,点的坐标就是当前鼠标的位置,每条线有两个端点,根据圆点的坐标可得出线段端点的坐标(上面的代码中我给了它们初始值)。

注:当添加矩形的时候出现(mouseover事件中监听变化),拖动矩形或者小球的时候不出现。

二、添加矩形(核心:起点、终点。矩形宽高,顶点都是这两个点推导猪来的)

svg 结构图: 方便理解代码

1,mousedown: 得到第一个点,创建矩形分配id,定义一些基本的样式(边框stroke,背景fill), 一个点是定义不了矩形的,还需要一个点,另一个点是移动鼠标的坐标(mousemove中找)

mouseDown(e) {
  const that = this
  if (!this.isDrag) { // 不是在拖拽模式
     const id = new Date().getTime() + '' // 获取一个不重复的id,给矩形分配一个id
     const xy = [(+e.offsetX), (+e.offsetY)] // 获取鼠标按下的坐标
     D3.select('#rect-g').append('g').attr('id', `rect-g-${id}`).append('rect').attr('id', `rect-g-${id}-rect`).attr('x', xy[0]).attr('y', xy[1]).attr('stroke', 'yellow').attr('fill', 'yellow').attr('fill-opacity', 0.1)
        this.rectData = xy // 记录下鼠标按下的坐标
        this.start = true // 添加矩形开始了(为了在moseover监控另一个点)
        this.startDom = `rect-g-${id}-rect` // 记录当前添加矩形的id(有了id可以快速定位矩形)
      }
    },

2,mouseomove: 在mousedown中我们得到了一个坐标点xy(x1, y1)(固定的,十字中心点的对角),在mousemove中我们又得到一个点dot(x2, y2)(动态的, 十字中心点),这两个点将帮助我们得到矩形的顶点坐标和宽高,如图所示红圈为矩形的顶点坐标top(x0, y0)(简单计算可得), 宽为Math.abs(x1 -x2), 高为Math.abs(y1 -y2)

mouseMove(e) {
  if (!this.isDrag) { //判断不是在拖拽状态下
    const xy = [(+e.offsetX), (+e.offsetY)] // 获取移动的鼠标的坐标点
    // 先处理十字坐标,圆点和两条线
    D3.select('#line-x').attr('x1', 0).attr('y1', xy[1]).attr('x2', 700).attr('y2', xy[1]).attr('stroke', 'white').attr('stroke-width', 2)
    D3.select('#line-y').attr('x1', xy[0]).attr('y1', 0).attr('x2', xy[0]).attr('y2', 700).attr('stroke', 'white').attr('stroke-width', 2)
    D3.select('#line-circle').attr('cx', xy[0]).attr('r', 6).attr('cy', xy[1]).attr('fill', 'red')
    if (this.start) { // 正在添加矩形(mousedown把start设为true)
      let top // 判断当前矩形的顶点坐标(矩形是由顶点,宽高定义的)
      if (xy[0] >= this.rectData[0]) {
          if (xy[1] >= this.rectData[1]) {
          // 右下
            top = this.rectData
          } else {
          // 右上
            top = [this.rectData[0], xy[1]]
          }
        } else {
          if (xy[1] >= this.rectData[1]) {
          // 左下
            top = [xy[0], this.rectData[1]]
          } else {
          // 左上
            top = xy
          }
        }
        // 动态修改矩形的属性值startDom是在mousedown中记录的矩形的id
        D3.select(`#${this.startDom}`).attr('x', top[0]).attr('y', top[1]).attr('width', Math.abs(this.rectData[0] - xy[0])).attr('height', Math.abs(xy[1] - this.rectData[1]))
        }
      }
    },

mouseup: 结束添加矩形的时刻,在mouseup中我们只需要将start改为false 矩形便固定了(start变为false后,mousemove便不会再对那个矩形进行动态监控)

mouseUp(e) {
  if (!this.isDrag) { // 判断不是在拖拽状态
    this.start = false // 添加矩形动作取消
    this.startDom = '' // 
    this.rectData = [] // 参考mousedown
  }
}

看到这里,添加矩形的任务已经完成,看起来上面我写了这么多其实都是非常简单直白的逻辑,前端小白都能一眼看穿吧。接下来就是拖拽

拖拽功能

当进入拖拽状态的时候(isDrag = true),鼠标移动到(mouseenter)矩形上面会出现4个小球,如图:

出现4个小球的事件是在添加矩形的时候就绑定通过.on('mouseenter', function(){}) 代码如下:

**注:**这里没有贴添加矩形的全部代码,因为前面已经展示过添加矩形的其他部分代码

 D3.select('#rect-g').append('g').attr('id', `rect-g-${id}`).append('rect').attr('id', `rect-g-${id}-rect`)
 .attr('x', xy[0]).attr('y', xy[1]).attr('stroke', 'yellow').attr('fill', 'yellow').attr('fill-opacity', 0.1)
          .on('mouseenter', function() {
            if (that.isDrag) {
              D3.select(this).attr('cursor', 'pointer') // 改变鼠标指针样式
              const xy = [(+D3.select(this).attr('x')), (+D3.select(this).attr('y'))] // 顶点坐标
              const wh = [(+D3.select(this).attr('width')), (+D3.select(this).attr('height'))] // 矩形宽高
              const dots = [xy, [xy[0] + wh[0], xy[1]], [xy[0] + wh[0], xy[1] + wh[1]], [xy[0], xy[1] + wh[1]]] // 得到四个圆点
              const id = D3.select(this)._groups[0][0].parentNode.id // 矩形父容器的id
              D3.select(`#${id}`).selectAll('circle').data(dots).enter().append('circle')
                .attr('cx', d => d[0]).attr('cy', d => d[1]).attr('r', 6).attr('fill', 'yellow').attr('parent', id)
                .on('mouseenter', function() {
                  that.Drag(D3.select(this)) 将圆添加到拖拽方法中
                })
              that.Drag(D3.select(this)) // 将矩形添加到拖拽方法中
            }
          })

上面代码很简单,就是进入拖拽状态(isDrag = true)的时后在矩形的四个角添加4个圆(圆心, 半径组成), 四个圆心坐标就是矩形四个角的坐标:矩形顶点(x, y),那其他三个坐标为(x+width, y),(x+width, y+height), (x, y+height)

那个that.Drag(XXX)啥意思呢,就是把当前的元素添加到D3创建的拖拽实例中(这里面定义了拖拽开始到拖拽结束的行为动作)

好了,现在开始我们最重要的拖拽方法:

第一步: 构建拖拽实例

拖拽实例作为一种工具使用,我们可以在很早的时候就进行创建,我们在创建svg画布的时候就调用createDrag()方法,这个方法里面我们会借助D3的拖拽构造器实现拖拽功能

 // 创建svg画布、定义宽高
 const svg = D3.select('#container5').append('svg').attr('width', this.width).attr('height', this.height)
 this.createDrag()
注: 拖拽我们分两种情况: 1,拖拽矩形(只改变矩形位置, 宽高不变),2,拖拽小球(宽高,位置都会变)createDrag方法是这个里面最复杂的方法,但只是代码多,逻辑很简单,莫慌。

*** 拖拽矩形(这个简单,先来干它)**

 // 创建拖拽实例
    createDrag() {
      let color, widget
      const that = this
      this.Drag = D3.drag() // 创建D3内置函数
        .on('start', function(e) { // 开始拖拽那刻触发
          color = D3.select(this).attr('fill')
          widget = D3.select(this).attr('fill', 'lime')
          const dot = [(+e.sourceEvent.offsetX), (+e.sourceEvent.offsetY)] // 实时记录点的坐标
          const id = widget._groups[0][0].parentNode.id // 获取父元素的id
          if (widget._groups[0][0].localName === 'rect') { // 当拖拽对象为矩形
            widget.attr('o-x', dot[0] - (+widget.attr('x'))).attr('o-y', dot[1] - (+widget.attr('y')))
            D3.select(`#${id}`).selectAll('circle').each(function() {
              const origin = [dot[0] - (+D3.select(this).attr('cx')), dot[1] - (+D3.select(this).attr('cy'))]
              D3.select(this).attr('o-x', origin[0]).attr('o-y', origin[1])
            })
          } 
        })
        .on('drag', function(e) { // 拖拽过程中触发
          const dot = [(+e.sourceEvent.offsetX), (+e.sourceEvent.offsetY)] // 实时记录点的坐标
          if (widget._groups[0][0].localName === 'rect') { // 拖拽对象为矩形
            const id = widget._groups[0][0].parentNode.id // 获取父元素的id
            widget.attr('x', dot[0] - (+widget.attr('o-x'))).attr('y', dot[1] - (+widget.attr('o-y'))) // 更新矩形的顶点信息
            D3.select(`#${id}`).selectAll('circle').each(function() {
              D3.select(this).attr('cx', dot[0] - (+D3.select(this).attr('o-x'))).attr('cy', dot[1] - (+D3.select(this).attr('o-y')))
            })
          }
        })
        .on('end', function(e) {
          widget.attr('fill', color)
          widget = null // 被拖拽的元素设为空
        })
    },
步步解析

D3.drag()是D3内置的一个拖放方法,提供了'start':开始拖放,'drag':拖放中, 'end':拖放结束三个钩子函数, 在前面的讲解中当进入拖拽转态(isDrag=true),鼠标移入矩形(圆形)(mouseenter)时将当前元素添加至拖拽方法中(that.Drag()),就可以对元素进行拖拽。

注:上面代码中有很多(+xxxx), 这个是干嘛的呢, 因为我们获取属性值的时候大都是获取的字符串,我们要对他们进行加减运算, 通过(+)将他们转化成number 这样就不会出现bug

widget: 我们在on('start')中将被拖拽元素赋值给widget,这样我们就可以通过widget对元素进行操作

前面讲了,我们拖拽矩形的时候,其实只有位置会发生改变,宽高不变,即:只有顶点坐标(x, y)改变,那么如何知道拖拽过程中实时的顶点坐标呢?简单,这个方法太多了,估计读者能想出很多很多中,我来说一种吧。

当我们开始拖拽的那一刻,我们会的得到一个坐标点(x0, y0),那么我就可以得到顶点坐标(x, y)的相对位置

这就完了吗?没有哦, 除了矩形位置发生变化,别忘了矩形四个角上还有4个圆的位置也需要改变,方法同这个一样,记录四个小球同(x0, y0)的相对位置。也许有些小伙伴看我上面的代码有丢丢费力,主要是写的不太工整,但这是次要的,思路在就行。找到移动后的位置的方法有很多很多,你完全可以用另一种方法来实现它。

接下来最后一步:拖拽小球,这个会导致矩形的位置大小都会改变,比上面的复杂一丢丢。不慌,这是最后一步啦,看完就可以回家吃饭啰!!!

先来看看效果:

发现规律没有:当你选择拖动一个小球的时候, 它的对角的小球(也矩形的一个角)它的位置(x0, y0)不变, 另一个实时移动的点的坐标(x1, y1)在on('drag')中也可以得到!!!!有没有发现,这不就跟最开始的添加矩形一模一样吗!!!!!(x0, y0)这个不变的相当于mousedown得到点, 变化的点就是mousemove中得到的, 矩形就出来啦,通过两个点就可以得到矩形的顶点和宽高,我们只需要和添加矩形的时候一样,动态的改变矩形的顶点、宽高属性即可

废话不多说,上代码:

 // 创建拖拽实例
    createDrag() {
      let color, widget
      const that = this
      this.Drag = D3.drag()
        .on('start', function(e) { // 开始拖拽那刻触发
          color = D3.select(this).attr('fill')
          widget = D3.select(this).attr('fill', 'lime')
          const dot = [(+e.sourceEvent.offsetX), (+e.sourceEvent.offsetY)] // 实时记录点的坐标
          const id = widget._groups[0][0].parentNode.id // 获取父元素的id
          if (widget._groups[0][0].localName === 'circle') { // 当拖拽对象为圆形
            // 获取矩形的一些信息, x, y, width, height
            const cxy = [(+D3.select(`#${id}-rect`).attr('x')), (+D3.select(`#${id}-rect`).attr('y')), (+D3.select(`#${id}-rect`).attr('width')), (+D3.select(`#${id}-rect`).attr('height'))]
            const dot = [(+D3.select(this).attr('cx')), (+D3.select(this).attr('cy'))]
            // 判断拖拽点对角的那个顶点
            if (dot[0] > cxy[0]) {
              if (dot[1] > cxy[1]) {
                // 右下
                that.topDot = [cxy[0], cxy[1]]
              } else {
                // 右上
                that.topDot = [cxy[0], cxy[1] + cxy[3]]
              }
            } else {
              if (dot[1] > cxy[1]) {
                // 左下
                that.topDot = [dot[0] + cxy[2], cxy[1]]
              } else {
                // 左上
                that.topDot = [cxy[0] + cxy[2], cxy[1] + cxy[3]]
              }
            }
           }
        })
        .on('drag', function(e) { // 拖拽过程中触发
          const dot = [(+e.sourceEvent.offsetX), (+e.sourceEvent.offsetY)] // 实时记录点的坐标
          if (widget._groups[0][0].localName === 'circle') { // 拖拽对象为矩形
            // 判断新的点相对于原顶点的位置信息,获取新的顶点坐标
            let top
            if (dot[0] >= that.topDot[0]) {
              if (dot[1] >= that.topDot[1]) {
                // 右下
                top = that.topDot
              } else {
                // 右上
                top = [that.topDot[0], dot[1]]
              }
            } else {
              if (dot[1] >= that.topDot[1]) {
                // 左下
                top = [dot[0], that.topDot[1]]
              } else {
                // 左上
                top = dot
              }
            }
            const id = widget.attr('parent') // 获取父元素的id
            // 更新矩形的信息
            D3.select(`#${id}-rect`).attr('x', top[0]).attr('y', top[1])
              .attr('width', Math.abs(that.topDot[0] - dot[0])).attr('height', Math.abs(dot[1] - that.topDot[1]))

            D3.select(`#${id}`).selectAll('circle').remove() // 删除四个球
            const rect = D3.select(`#${id}-rect`)
            const circles = [ // 获取四个圆点的坐标
              [(+rect.attr('x')), (+rect.attr('y'))],
              [(+rect.attr('x')), (+rect.attr('y')) + (+rect.attr('height'))],
              [(+rect.attr('x')) + (+rect.attr('width')), (+rect.attr('y'))],
              [(+rect.attr('x')) + (+rect.attr('width')), (+rect.attr('y')) + (+rect.attr('height'))]
            ]
            D3.select(`#${id}`).selectAll('circle').data(circles).enter().append('circle')
              .attr('cx', d => d[0]).attr('cy', d => d[1]).attr('r', 6).attr('fill', 'yellow').attr('parent', id)
              .on('mouseenter', function() {
                that.Drag(D3.select(this))
              })
          }
        })
        .on('end', function(e) {
          widget.attr('fill', color)
          widget = null // 被拖拽的元素设为空
        })
    },

上面的代码好像有点长啊, 不必怕,这些逻辑很简单,小学的加减乘除, 不信往下看:

start: 开始拖动 这里面我们做了什么呢,拖动一个小球, 我们就找到小球对角的坐标(矩形的一个角的坐标), 图上红点(这个图只显示了一种,还有三种,所以需要判断, 如何判断通过你当前拖拽的点与矩形顶点相比较可得), 可以参考最上面的添加矩形的逻辑。

drag:拖动过程中 这里我们做了啥?计算实时的顶点坐标, 宽高,这个逻辑和添加矩形时一样,得到新的顶点和宽高然后进行复制即可。 与拖动矩形不同的是,四个小球,我们没有通过改变小球中心坐标来达到改变小球的位置,而是把四个小球移除,重新建立新的四个小球。有人会问我怎么知道新的小球的中心坐标呢?矩形已经得到了,那么矩形的四个角坐标我也可以简单得到, 四个小球的中心坐标分别是四个角的坐标。

这个Demo使用vue的,这是vue文件的地址,你们也可以拿来自己测试下。

结语:

虽然说这个是基于D3实现的,但用它的思路,你完全可以用canvas或者其他东西来实现。这是我第一次写掘金文章,对于语文水平很糟糕的我来说真的吃奶的劲都用上了,这是一个简单地基础版本,如果点赞数超过3个,2.0很快就会来到。

最后的最后,你们是不是很奇怪,国庆节不去玩写啥破玩意,因为因为 我 身份证掉了 。。。

哈哈,祝大家国庆快乐