echarts 标记线拖拽

0 阅读7分钟

前言

很久没有写文了,最近发生的事情太多,恍惚看穿了我生,又恍惚疲惫不堪。

闲话暂叙,来讲正题。今天我们要讲的是一个特殊的应用场景,echarts 标记线拖拽。我在找资料的时候呢,有位大佬拿它来做过音轨校准,出于行业保密限制,这里本小蚂蚁暂不能告诉大家我们这边的真实应用场景,大家可以按需拿掐。

困境

echarts 标记线拖拽 具体实现的是一个什么功能呢?为了方便理解,我先给大家简单描述下,首先,我们有一条复杂的折线渲染在 echarts 上,然后,这条折线上的波峰和波谷,有一些被打上了标记线,但是呢,这些初始的标记线有可能不准确,需要手动拖动这些标记线去校准,然后再将校准后的数据存到数据库,大致上就是这样一个过程。

我们来看看实际效果: 钉钉录屏_2025-07-01 190608.gif

这个场景的核心问题点其实就是,怎么实现对这些标记线的拖拽交互,因为它并不是 dom,并且它受控于 echarts。

突破

还是得益于开文中提到的那位大哥,我们了解到,echarts 里有这样一个配置项:graphicgraphic 是什么呢 ?

官网给的定义是:原生图形元素组件。可以支持的图形有:image、text、circle、sector、ring 等等等等。

什么意思呢,就是这个属性可以用来画各种纹路的色块,可以是我们自己定义的图片,文本,也可以是插件提供的各种预制图形。

官方为了更好的解释它的用途,在示例中给我们做了一个水印和一个文本框。

image.png

没错,水印和文本块都是 echarts 用 canvas 画的。而且呢,这些图块是支持 click、mousemove、drag 事件的。

奇袭

目前为止,我们得到了可以塑形和拖拽的色块,以及原始的数据折线。那么,怎么解决我们开文提到的问题呢 ?怎么才能利用这些条件去拖拽标记线呢 ?

答案就是:遮盖。

相信很多同学看到这里,已经猜到了故事的结尾了,因为“遮盖”这种操作,在前端的一些破局战中往往能出奇效。秒懂的小伙伴,看到这里请伸出高贵的小手手,给我点个赞。我仿佛已经看到了你们左嘴角那微妙的弧度。

所以,到底是什么意思呢 ?

  1. 首先,我们用 graphic 依托原始标记线数据,标记线的上层一对一画一个矩形方块,大致长这样:

image.png

  1. 第二,我们在 graphic 中实现拖拽方法拖动色块。注意,这里,我们要将色块拖动时的 x 轴的数据实时回填给对应的标记线,这里有个坑,我们到后面讲代码的时候再细说。
  2. 第三,并设置 invisible 属性为 true,或者将色块的颜色设置成透明。

好了,思路就是这么多,接下来,我们来码代码。

第一步,构建图形实例,画折线和标记线


// 构建 echarts 实例

const createChart = () => {
  
  // 基础配置,这里为了不让代码页面冗长,将基础配置做了抽离,稍后会在文中提供
  const options = GetBaseBar() 
  
  // 构建实例
  insChart.value = echarts.init(refmap.value)
  
  // 模拟获取所有散点
  points.value = getPoints()
  options.series[0].data = points.value.map(item => {
    return [item.no, item.value]
  })
    
  // 模拟获取所有标记线
  options.series[0].markLine = getMarklines()
  insChart.value.setOption(options)
  
}

这里有个细节,getMarklines 不仅仅只是返回了标记线数据,它还做了一些其他事。


const getMarklines = () => {
  
  // lines 是存储标记线原始数据的变量
  if (!lines.value.length) {
     lines.value = getLines(points.value) // 模拟获取标记线数据
  }
  
  return {
    symbol: 'none',
    animation: false,
    silent: true,
    data: lines.value.map((item, x) => { // 处理数据
      return {
        id: x,
        name: item.name,
        label: {
          position: 'insideEndTop',
          formatter: (e) => {
            return e.name + '(' + e.value + ')' + '【' + item.no + '】'
          }
        },
        symbol: 'none',
        xAxis: item.no,
        lineStyle: {
          type: 'solid',
          color: '#FF0000'
        },
        tooltip: { show: false }
      }
    })
  }

}

这里为什么要这么写呢?为什么不直接写在创建方法里,非要单构建一个方法呢?因为后面在拖拽的过程中,会需要频繁的变更标记线的数据。

第二步,画图块


const dragLineRender = () => {

  insChart.value.setOption({
  
    graphic: lines.value.map((item, x) => {
        
      // 将点坐标转换为像素点坐标
      const el = insChart.value.convertToPixel('grid', [item.no, 0])

      return {
        id: x,
        type: 'line', // 色块形状
        left: el[0] - 15, // 重要,坐标补偏
        z: 300,
        shape: { // 图块起终点
          x1: 0,
          y1: 0,
          x2: 0,
          y2: 500
        },
        draggable: 'horizontal',
        invisible: true,
        style: {
          stroke: 'rgba(87, 140, 240, 0.6)',
          lineWidth: 30
        },
        ondrag () {
          
          // 像素坐标转换为点坐标
          const pos = insChart.value.convertFromPixel('grid', this.position)
          const x_min = Math.min(...points.value.map(item => item.no))
          const x_max = Math.max(...points.value.map(item => item.no))
            
          // 外溢限制
          if (pos[0] <= x_min) {
            item.no = x_min
          } else if (pos[0] >= x_max) {
            item.no = x_max
          } else {
            item.no = pos[0]
          }

          lineFollow() // 标记线处理

        }
      }
    })
  })

}

好,有几个点我们来解释一下:

  1. convertToPixelecharts 实例的一个坐标转换方法,它接收两个参数,第一个参数是坐标系类型,如上面代码中的 grid,第二个参数是需要转换为 px 定位的目标点,px 定位的原点即 canvas 容器的左上角。这里需要注意的是,因为后续的实现里,我们只需要用到该转换点的 x 轴值,所以代码 [item.no, 0] 中的 0 没有实际意义,可以写任何值。

  2. shape 中配置的色块起终点控制的是图像起、终锚点的位置,它并不能用来设置色块的大小。色块的颜色和大小需要在 style 属性中进行设置。为了更直观,我们看下面两个配置的效果:


 shape: {
  x1: 0,
  y1: 0,
  x2: 30,
  y2: 500
},
style: {
  stroke: 'rgba(87, 140, 240, 0.6)'
},

image.png


 shape: {
  x1: 0,
  y1: 0,
  x2: 30,
  y2: 500
},
style: {
  stroke: 'rgba(87, 140, 240, 0.6)',
  lineWidth: 30
}

image.png

  1. convertFromPixel: 在图块拖拽的过程中将每帧的的位置数据转换为最标数据回写进标记线中。

第三步,重绘


const lineFollow = () => {

  insChart.value.setOption({
    series: [{
      id: 'a', // 于原始数据的配置对应,可以通过其他方式省略
      markLine: getMarklines() // 上文中提及,返回数据变更后标记线的配置数据
    }],
    graphic: lines.value.map((item, x) => { // 重新计算并渲染色块
      const el = insChart.value.convertToPixel('grid', [item.no, -100])
      return {
        id: x,
        left: el[0] - 15
      }
    })
  })

}

这里大家可能有个疑问,为什么重绘还需要重绘 graphic 呢?因为我们在前面的实现步骤中会发现,到上一步为止,我们已经可以随意的滑动色块了。注意了,这里有个坑,就是我们在随意滑动色块的这个过程,其数据的变动是不会被录入到实例的缓存数据中的,当我们滑动色块后,如果重新设置了 echartsoptions 值,graphic 会被重置到原始位置,这就会导致色块在原地颤抖,根本拖不动。

关于 seriesid 的问题,是因为在初始化的时候,我给了一个默认配置,配置数据里写了一个 id,这里仅仅只是为了数据匹配,没有其他深意。

鸣金

好了,到这里为止,我们的代码就差不多码完了,下面附上一些辅助代码。

export const GetBaseBar = () => {
  return {
    title: {
      left: 'left',
      textStyle: {
        color: '#FFFFFF',
        fontSize: 14
      }
    },
    tooltip: {
      triggerOn: 'none',
      formatter: (params) => {
        return (
          'X: ' +
          params.data[0].toFixed(2) +
          '<br>Y: ' +
          params.data[1].toFixed(2)
        )
      }
    },
    grid: {
      top: 60,
      bottom: 20,
      left: 86,
      right: 86
    },
    legend: {
      show: true,
      top: 6,
      right: 22,
      itemWidth: 8,
      itemHeight: 8,
      textStyle: {
        // color: '#ffffff'
      }
    },
    xAxis: {
      min: 0,
      type: 'value',
      axisLine: {
        onZero: false,
        show: false
      },
      axisLabel: {
        // color: '#ffffff'
      },
      splitLine: {
        show: false
      }
    },
    yAxis: {
      min: 0,
      max: 6000,
      type: 'value',
      axisLine: {
        onZero: false,
        show: false
      },
      axisLabel: {
        // color: '#ffffff'
      },
      splitLine: {
        lineStyle: {
          color: ['rgba(68, 170, 255, 0.5)'],
          type: 'dashed'
        }
      }
    },
    series: [{
      id: 'a',
      type: 'line',
      smooth: true,
      showSymbol: false,
      lineStyle: {
        color: '#448EF7'
      },
      data: [],
    }]
  }
}


export function getPoints () {

  return new Array(100).fill(null).map((_, x) => {
    return {
      no: x, value: +parseInt(Math.random() * 5000)
    }
  })

}

export function getLines (e) {

  const num = +parseInt(e.length * 0.1)
  return new Array(num).fill(null).map((_, x) => {

    const rx = +parseInt(Math.random() * e.length)
    return { ...e[rx], name: `point_${x}`, isDrag: 0 }

  })

}

写在最后

首先,本小蚂蚁讲一下为什么总是喜欢起一些奇怪的段落标题,正所谓身在浮华,不由己得;心在江湖,光风霁月。

文中若有纰漏之处,还望各路大佬不吝指正,以提携本小蚂蚁奋进!最后的最后,写文不易,还望贵手赐个赞赞。