可视化大屏-路径-箭头动画

先上效果图

image

之前在工作中需要给可视化大屏写些动画效果,其中就有上图展示的多段路径效果,写的时候也踩了些坑,避免大家后续工作中遇到相似功能不好下手,这里分享给小伙伴们。

组件使用如下,可以看到,主要就是在背景图上写的动画:

image.png

实现原理:

使用的是echarts的路径图,也是就是type:‘lines’这个系列。可先看下我发布的这个“基础版本”基础-多段线-路径图,考虑到多个页面会使用到当前效果,因此对“基础版本”封装成了一个比较通用的组件,注意echarts版本为4.4.0及其以上

使echarts 渲染盒子和背景图片(可以是img标签)宽度高度一致,echarts 渲染盒子的层级z-index高于要写动画的图片,以左下角为原点建立坐标系(这样方便测量坐标),整个坐标系宽高(即xAxis和yAxis的最大值)为图片宽高,然后量好各个点的坐标,结合基础-多段线-路径图实现最终动画。

image.png

最后对该组件升级以满足更多需求,如页面缩放时,保证点不错位,如使组件支持多段点分别配置单独的颜色、速度,如下:
路径3.gif

下面进行具体实现,分v1.0和v2.0两个版本,不想看的可直接翻到最后查看最终实现代码

路径组件v1.0版本开发要求

1.核心功能就是上面的基础-多段线-路径图
2.因为是在背景图上(也可以是img标签,只要保证图片和组件宽高一致即可)写一层箭头运动的动画,就要考虑到图片拉伸问题,图片拉伸需要保证动画始终在正确位置上,不会错位。
3.使用组件时要方便,配置点位要简单。

路径组件1.0版本-代码如下:

<template>
  <div class="chart-box" :id="id"></div>
</template>
<script>
  export default {
    name: 'linesChartAnimate',
    props: {
      id: {
        type: String,
        default: 'ChartBox'
      },
      imgWH: {
        type: Object,
        default(){
          return {
            width: 882, // 当前这张图是 882*602的图
            height: 602
          }
        }
      },
      dotsArr: { // 运动点集合
        type: Array,
        default(){
          return [
            [ // 这个括号里代表的一组数据的运动,即从点[205, 275]运动到点[263, 275]
              [205, 275],
              [263, 275],
            ],
            [ // 这组点里有四个点
              [206, 267],
              [284, 267],
              [284, 413],
              [295, 413],
            ],
          ]
        }
      },
      speed: { // 转速
        type: Number,
        default: 7
      }
    },
    data () {
      return {
        myChart: '',
        // 注意:因为图片在现实的时候可能会拉伸,所以设置actualWH和imgWH两个变量
        actualWH: {
          width: 0,
          height: 0
        }
      }
    },
    mounted () {
      this.actualWH = { // 渲染盒子的大小
        width: this.$el.clientWidth,
        height: this.$el.clientHeight
      }
      this.myChart = this.$echarts.init(document.getElementById(this.id))
      this.draw()
    },
    methods: {
      getLines(){
        return {
          type: 'lines',
          coordinateSystem: 'cartesian2d',
          // symbol:'arrow',
          zlevel: 1,
          symbol: ['none', 'none'],
          polyline: true,
          silent: true,
          effect: {
            symbol: 'arrow',
            show: true,
            period: this.speed, // 箭头指向速度,值越小速度越快
            trailLength: 0.01, // 特效尾迹长度[0,1]值越大,尾迹越长重
            symbolSize: 5, // 图标大小
          },
          lineStyle: {
            width: 1,
            normal: {
              opacity: 0,
              curveness: 0.4, // 曲线的弯曲程度
              color: '#3be3ff'
            }
          }
        }
      },
      getOption () {
        // 点合集-在图片上一个一个量的,注意以渲染盒子左下角为原点,点取值方法:以图片左下角为原点,量几个线段点的(x,y)
        let dotsArr = this.dotsArr

        // 点的处理-量图上距离转换为在渲染盒子中的距离 start
        dotsArr.map(item => {
          item.map(sub => {
            sub[0] = (this.actualWH.width / this.imgWH.width) * sub[0] // x值
            sub[1] = (this.actualWH.height / this.imgWH.height) * sub[1] // y值
          })
        })
        // 点的处理-量图上距离转换为在渲染盒子中的距离 end

        // 散点图和lines绘制 start
        let scatterData = []
        let linesData = [] // 默认路径图点的路径
        let seriesLines = [] // 路径图
        dotsArr.map(item => {
          scatterData = scatterData.concat(item) // 散点图data
          linesData.push({
            coords: item
          })
        })

        // 默认路径图
        linesData && linesData.length && seriesLines.push({
          ...this.getLines(),
          data: linesData
        })
        // 散点图和lines绘制 end

        let option = {
          backgroundColor: 'transparent',
          xAxis: {
            // type: 'category',
            type: 'value',
            show: false,
            min: 0,
            max: this.actualWH.width,
            axisLine: {
              lineStyle: {
                color: 'red'
              }
            },
            splitLine: {
              lineStyle: {
                color: 'red'
              }
            }
            // data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
          },
          yAxis: {
            type: 'value',
            show: false,
            min: 0,
            max: this.actualWH.height,
            axisLine: {
              lineStyle: {
                color: 'red'
              }
            },
            splitLine: {
              lineStyle: {
                color: 'red'
              }
            }
            // type: 'category'
          },
          grid: {
            left: '0%',
            right: '0%',
            top: '0%',
            bottom: '0%',
            containLabel: false
          },
          series: [
            {
              zlevel: 2,
              symbolSize: 0,
              data: scatterData,
              type: 'scatter'
            },
            ...seriesLines
          ]
        };
        return option
      },
      // 绘制图表
      draw () {
        this.myChart.clear()
        this.resetChartData()
      },
      // 刷新数据
      resetChartData () {
        this.myChart.setOption(this.getOption(), true)
      }
    },
  }
</script>
<style scoped>
  .chart-box {
    width: 100%;
    height: 100%;
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
  }
</style>

注意上面两个变量:imgWH和actualWH,imgWH是在测量点坐标时的宽高,actualWH是指页面渲染时的实际宽高,初始时在mounted 中获取。

    mounted () {
      this.actualWH = { // 渲染盒子的大小
        width: this.$el.clientWidth,
        height: this.$el.clientHeight
      }
      this.myChart = this.$echarts.init(document.getElementById(this.id))
    },

在渲染图形前先将点位坐标根据比例换算为实际坐标


image.png

结合下面的option配置,到这里最终实现了不同大小图片在初始时动画能准确的定位


image.png

不知道小伙伴们看懂没,这里总结下这步操作:

首先渲染图表的盒子和背景图(可以是img)大小完全一致,然后配置echarts的option的x轴和y轴分别盒子的宽高,注意x,y轴的类型都为"value",然后grid配置上下左右都为0,再设置containLabel:false排除坐标轴的影响,这就实现了在图片上建立坐标系的完美对齐。

在测量点位的时候,无论是哪个宽高量的点(量点的时候也是左下角开始) ,比如下面这个点的坐标就是 [305,76],我量的时候是按照背景图1000 * 280(这就是imgWH的值)的大小量的,但页面实际渲染时盒子的大小实际是800 * 188(这就是actualWH获取到的值),直接使用点[305,76]肯定是不行的,因此需要按等比缩放计算出现在的值也就是[800 / 1000 * 305,188 / 280 * 75],这才是现在的实际点位。

image.png

路径4.gif

可以看到代码中配置echarts的option里有scatter这个系列,按理说这部分代码完全是多余的,但是实践测试,必须要有这项配置lines才能跑得起来,而且scatter至少要有一个点。其他代码没什么说的,看代码也能看懂,至此路径图简陋版v1.0开发完毕。

路径组件v2.0版本升级

1.在1.0版本上加上了页面resize事件,页面resize则echarts resize
2.1.0版本配置的颜色、运动速度等是通用的,这里扩展数据配置项,以支持对单条路径的配置,比如:箭头颜色、运行速度等

这里只贴部分关键代码,完整代码请移步页面底部

解决问题1,data中定义timer,然后定义如下方法:

image.png

在页面初始时调用


image.png

离开页面时销毁


image.png

然后优化交互
image.png

至此,问题1解决,到这里按住ctr+鼠标滚轮缩放页面时,可实现适配。

解决问题2:

数据更改,向下兼容,第一项为Object时可配置当前这组点的表现行为


image.png

image.png

核心实现,针对配置项单独生成一个series,这里小伙伴可能有疑问: 不能在一个series的lines中实现吗,为什么要每次单独配置一段路径动画都得push一个lines?答案是:不能,因为effect项只能针对每个lines。


image.png

至此问题二得到解决。

最后,我的项目是vue开发的,封装的vue组件-最终实现2.0版本-代码如下,可直接使用。若是react或者其他方式开发的,可参考代码自行开发。

<!--
路径图组件,针对图片上点需要有路径动画的情况
若图片有变化:
   1.修改 imgWH 的宽高为最新图片的宽高
   2.重新在原图上量出点合集并赋值给dotsArr
-->
<template>
  <div class="chart-box" :id="id" v-show="!this.timer"></div>
</template>
<script>
//  const merge = require('webpack-merge');
  export default {
    name: 'linesChartAnimate',
    props: {
      id: {
        type: String,
        default: 'ChartBox'
      },
      imgWH: {
        type: Object,
        default(){
          return {
            width: 882, // 当前这张图是 882*602的图
            height: 602
          }
        }
      },
      dotsArr: {
        type: Array,
        default(){
          return [
//  eg:           [
//                  [140,338], // 点运动起点 -- [x,y]
//                  [202,338], // 点运动终点
//                ]
            // 左上点合集
            [
              { // 第一项可为对象,是当前这组点的配置
                color: 'red', // 颜色
                symbol:'rect', // 类型-'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
                speed: 3 // 运动时间
              },
              [140, 338],
              [202, 338],
              [202, 329],
            ],
            [
              [141, 227],
              [160, 227],
              [196, 100],
              [202, 100],
              [202, 107],
            ],

            // 上中点
            [
              [205, 275],
              [263, 275],
            ],
            [
              [206, 267],
              [284, 267],
              [284, 413],
              [295, 413],
            ],
            [
              [208, 257],
              [605, 257],
              [605, 262],
            ],
            [
              [486, 272],
              [582, 272],
              [582, 307],
            ],
            [
              [563, 486],
              [582, 486],
              [582, 440],
            ],

            // 底部点合集
            [
              [113, 123],
              [113, 59],
              [625, 59],
            ],
            [
              [677, 59],
              [727, 59],
              [727, 67],
              [813, 67],
            ]

          ]
        }
      },
      speed: { // 速度
        type: Number,
        default: 7
      }
    },
    data () {
      return {
        myChart: '',
        // 注意:因为图片在现实的时候可能会拉伸,所以设置actualWH和imgWH两个变量
        actualWH: {
          width: 0,
          height: 0
        },
        timer: null
      }
    },
    mounted () {
      this.actualWH = { // 渲染盒子的大小
        width: this.$el.clientWidth,
        height: this.$el.clientHeight
      }
      this.myChart = this.$echarts.init(document.getElementById(this.id))
      this.draw()
      this.eventListener(true)
    },
    methods: {
      getLines(){
        return {
          type: 'lines',
          coordinateSystem: 'cartesian2d',
          // symbol:'arrow',
          zlevel: 1,
          symbol: ['none', 'none'],
          polyline: true,
          silent: true,
          effect: {
            symbol: 'arrow',
            show: true,
            period: this.speed, // 箭头指向速度,值越小速度越快
            trailLength: 0.01, // 特效尾迹长度[0,1]值越大,尾迹越长重
            symbolSize: 5, // 图标大小
          },
          lineStyle: {
            width: 1,
            normal: {
              opacity: 0,
              curveness: 0.4, // 曲线的弯曲程度
              color: '#3be3ff'
            }
          },
        }
      },
      getOption () {
        // 点合集-在图片上一个一个量的,注意以渲染盒子左下角为原点,点取值方法:以图片左下角为原点,量几个线段点的(x,y)
        let dotsArr = this.dotsArr

        // 点的处理-量图上距离转换为在渲染盒子中的距离 start
        dotsArr.map(item => {
          item.map(sub => {
            if (Object.prototype.toString.call(sub) !== '[object Object]') { // item可能配置了当前这组点的运动时间
              sub[0] = (this.actualWH.width / this.imgWH.width) * sub[0] // x值
              sub[1] = (this.actualWH.height / this.imgWH.height) * sub[1] // y值
            }
          })
        })
        // 点的处理-量图上距离转换为在渲染盒子中的距离 end

        // 散点图和lines绘制 start
        let scatterData = []
        let linesData = [] // 默认路径图点的路径
        let seriesLines = [] // 路径图
        dotsArr.map(item => {
          if (Object.prototype.toString.call(item[0]) === '[object Object]') { // 单独配置路径
            let cArr = item.slice(1)
            if (!cArr.length) return // 无数据跳过
            scatterData = scatterData.concat(cArr) // 散点图data

            let opt = {
              ...this.getLines(),
              zlevel: 2,
              data: [{
                coords: cArr
              }]
            }

            //  配置
            item[0]['symbol'] && (opt.effect.symbol = item[0]['symbol'])
            item[0]['speed'] && (opt.effect.period = item[0]['speed'])
            item[0]['color'] && (opt.lineStyle.normal.color = item[0]['color'])

            // 可以更改成下面这种-传入配置项
            // opt = merge(opt,item[0])
            seriesLines.push(opt)
          } else { // 使用默认路径配置
            scatterData = scatterData.concat(item) // 散点图data
            linesData.push({
              coords: item
            })
          }

        })

        // 默认路径图
        linesData && linesData.length && seriesLines.push({
          ...this.getLines(),
          data: linesData
        })
        // 散点图和lines绘制 end

        let option = {
          backgroundColor: 'transparent',
          xAxis: {
            type: 'value',
            show: false,
            min: 0,
            max: this.actualWH.width,
            axisLine: {
              lineStyle: {
                color: 'red'
              }
            },
            splitLine: {
              lineStyle: {
                color: 'red'
              }
            }
          },
          yAxis: {
            type: 'value',
            show: false,
            min: 0,
            max: this.actualWH.height,
            axisLine: {
              lineStyle: {
                color: 'red'
              }
            },
            splitLine: {
              lineStyle: {
                color: 'red'
              }
            }
            // type: 'category'
          },
          grid: {
            left: '0%',
            right: '0%',
            top: '0%',
            bottom: '0%',
            containLabel: false
          },
          series: [
            // 多段点
            {
              zlevel: 2,
              symbolSize: 0,
              data: scatterData,
              type: 'scatter'
            },
            ...seriesLines
          ]
        };
        return option
      },
      // 绘制图表
      draw () {
        this.myChart.clear()
        this.resetChartData()
      },
      // 刷新数据
      resetChartData () {
        this.myChart.setOption(this.getOption(), true)
      },

      // 。。。。。 resize 相关优化 start 。。。。。。
      clearTimer(){
        this.timer && clearTimeout(this.timer)
        this.timer = null
      },
      eventListener(bool){
        if (!bool) { // 销毁
          window.removeEventListener('resize', this._eventHandle)
          this.clearTimer()
        } else {
          window.addEventListener('resize', this._eventHandle, false)
        }
      },
      // 优化-添加resize
      _eventHandle(){
        this.clearTimer()
        this.timer = setTimeout(() => {
          this.clearTimer();
          this.$nextTick(() => {
            this.myChart && this.myChart.resize()
          })
        }, 500)
      },
      // 。。。。。 resize 相关优化 end 。。。。。。
    },
    beforeDestroy () {
      this.myChart && this.myChart.dispose()
      this.eventListener() // 销毁
    }
  }
</script>
<style scoped>
  .chart-box {
    width: 100%;
    height: 100%;
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
  }
</style>

写在最后

总算写完了,这是我的第一篇博客,但不会是最后一篇,如果对你有帮助的话请留个关注,谢谢啦。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):