G2图表实现滚动

示例项目地址:g2-scroll-demo

观前提醒

下文实现是基于G2的^3.5.11版本,如果用不是3.x版本,可能一些api和实现是会有所区别的。
由于官方的文档有时查看起来有点问题,补充另外一个文档参考的地址,可以结合起来查看
g2 3.x官网文档参考
g2 3.x文档参考

背景

G2 是一套基于可视化编码的图形语法,以数据驱动,具有高度的易用性和扩展性,用户无需关注各种繁琐的实现细节,一条语句即可构建出各种各样的可交互的统计图表。

初次使用G2实现一个柱状图,我在想如果数据比较多的时候视图上的柱状图不是会很挤吗,影响视觉效果,因此希望在数据比较多的时候,柱状图可以水平的滚动。以下是我使用的实现方案:

思路

使用DataSet控制图表视图显示的区域,自定义一个滚动条组件,给用户滚动,滚动时更新DataSet的State,从而更新视图范围。
最终实现的效果如下:
在这里插入图片描述

具体实现

实现前需要安装依赖:

npm i -S @antv/data-set @antv/g2

定义一个自定义滚动条组件:

<template>
  <div class="scrollBar"
       ref="scrollBar"
       v-if="isShow">
    <div class="scrollBarInner"
         :style="scrollBarInnerStyle"
         ref="scrollBarInner"
         @mousedown="onmousedown"></div>
  </div>
</template>

<script>
/*
  滚动条:暂时只支持水平滚动
*/
export default {
  data () {
    return {
      isShow: false,
      scrollBarWidth: null,
      scrollBarInnerWidth: 0,
      tranX: 0,
      mousedownPosX: null
    }
  },
  computed: {
    scrollBarInnerStyle () {
      return {
        width: `${this.scrollBarInnerWidth}px`,
        transform: `translate3d(${this.tranX}px,0,0)`
      }
    }
  },
  beforeDestroy () {
    document.removeEventListener('mousemove', this.onmousemove)
    document.removeEventListener('mouseup', this.onmouseup)
  },
  methods: {
    /**
     * {Number} proportion - 滚动条占总长比例,[0,1],1时不需要显示
     * {Number} pos - 滚动条初始位置的起点坐标与总长的比值,范围:[0,(1-滚动条占总长比例)]
     */
    initScroll (proportion = 0, pos) {
      if (!proportion) {
        return
      }
      if (proportion >= 1) {
        this.isShow = false
        return
      } else {
        this.isShow = true
      }
      this.$nextTick(() => {
        const scrollBar = this.$refs.scrollBar
        const scrollBarInner = this.$refs.scrollBarInner
        if (scrollBar && scrollBarInner) {
          this.scrollBarWidth = scrollBar.offsetWidth
          const width = (this.scrollBarWidth * proportion) || 0
          this.scrollBarInnerWidth = width
        }
        if (pos >= 0) {
          this.switchPos(pos)
        }
      })
    },
    onmousedown (e) {
      this.mousedownPosX = this.getRelativePosX(e)
      this.mousedownTranX = this.tranX
      // 给全局绑定移动事件,结束时要移除事件
      document.addEventListener('mousemove', this.onmousemove.bind(this))
      document.addEventListener('mouseup', this.onmouseup.bind(this))
    },
    onmousemove (e) {
      if (this.mousedownPosX === null) {
        return
      }
      const currentMousePosX = this.getRelativePosX(e)
      // 处理边界问题
      let tranX = this.mousedownTranX + currentMousePosX - this.mousedownPosX
      tranX = this.getValidTranX(tranX)
      this.tranX = tranX
      this.$emit('updatePosRate', tranX / this.scrollBarWidth)
    },
    onmouseup (e) {
      this.mousedownPosX = null
      document.removeEventListener('mousemove', this.onmousemove)
      document.removeEventListener('mouseup', this.onmouseup)
    },
    // 获取鼠标相对于scrollBar的水平位置
    getRelativePosX (e) {
      const scrollBar = this.$refs.scrollBar
      if (!scrollBar) {
        return 0
      }
      const rect = scrollBar.getBoundingClientRect()
      const mounseX = Math.round(e.clientX - rect.left)
      return mounseX
    },
    // 切换位置:posRate是位置比例,是滚动条起始位置/滚动条长度的值,范围是[0,(1-显示所占比例)],needEmitUpdate:默认为false,即不会发送updatePosRate事件
    switchPos (posRate, needEmitUpdate = false) {
      let tranX = posRate * this.scrollBarWidth
      tranX = this.getValidTranX(tranX)
      this.tranX = tranX
      if (needEmitUpdate) {
        this.$emit('updatePosRate', tranX / this.scrollBarWidth)
      }
    },
    getValidTranX (tranX) {
      if (tranX < 0) {
        tranX = 0
      } else if (tranX + this.scrollBarInnerWidth > this.scrollBarWidth) {
        tranX = this.scrollBarWidth - this.scrollBarInnerWidth
      }
      return tranX
    }
  }
}
</script>

<style lang="scss" scoped>
.scrollBar {
  width: 100%;
}
.scrollBarInner {
  height: 6px;
  border-radius: 6px;
  background: rgba(#cdcdcd, 1);
}
</style>

实现一个简单的节流函数:

// 节流
export default function throttle (fn, time) {
  let timer = null
  return function () {
    let content = this
    let args = arguments
    if (!timer) {
      timer = setTimeout(() => {
        timer = null
        fn.apply(content, args)
      }, time)
    }
  }
}

接着实现图表:

<template>
  <div class="container">
    <h1>g2图标滚动方案2</h1>
    <p>使用DataSet控制图表视图显示的区域,自定义一个滚动条组件,给用户滚动,滚动时更新DataSet的State,从而更新视图范围。</p>
    <p>data长度:{{data.length}}</p>
    <div class="chart">
      <div class="chart-demo-wrap">
        <div id="chartDemo"></div>
        <div class="scrollBarWrap">
          <ScrollBar ref="scrollBar"
                     @updatePosRate="throttleUpdateDs" />
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import G2 from '@antv/g2'
import DataSet from '@antv/data-set'
import { getMockChartData } from '../api/api'
import ScrollBar from '@/components/ScrollBar/ScrollBar.vue'
import throttle from '@/common/js/throttle'

// 以10条作为基准
const BASE_LEN = 10
const REFRESH_INTERVAL = 5000 // 5秒

export default {
  components: {
    ScrollBar
  },
  data () {
    return {
      data: {},
      average: null
    }
  },
  created () {
    // 不需要与视图交互的变量都定义在created这里
    this.refreshTimer = null
    this.scrollProportion = 0
    this.chart = null
    this.chartView = null
    this.ds = null

    // 定时获取数据
    this.getDataRealTime()
  },
  beforeDestroy () {
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer)
    }
  },
  methods: {
    getDataRealTime () {
      this.getData()
        .then(() => {
          // 渲染图表
          this.renderChart()
          if (this.refreshTimer) {
            clearTimeout(this.refreshTimer)
          }
          this.refreshTimer = setTimeout(() => {
            // 请求之后重新设置定时器
            this.getDataRealTime()
          }, REFRESH_INTERVAL)
        })
        .catch(() => {
          // 不管请求成功还是失败都不会进入这里
        })
    },
    getData () {
      const randomLen = Math.max(1, Math.floor((Math.random()) * 100))
      return getMockChartData(randomLen)
        .then(res => {
          if (res.code === 200 && res.data) {
            console.log(res)

            this.data = res.data.list || []
            this.average = res.data.average > 0 ? res.data.average : null
          } else {
            this.data = []
            this.average = null
          }
        })
        .catch(() => {
          this.data = []
        })
    },
    renderChart () {
      const isFirstRender = !this.chartView
      if (isFirstRender) {
        this.initChart()
      } else {
        this.updateChart()
      }
    },
    initChart () {
      let baseRate = 1
      const data = this.data
      if (data.length > BASE_LEN) {
        baseRate = BASE_LEN / data.length
      }
      this.scrollProportion = baseRate
      this.$refs.scrollBar.initScroll(baseRate)
      this.ds = new DataSet({
        state: {
          start: 0,
          end: baseRate
        }
      })

      const dv = this.ds.createView('origin').source(data)
      dv.transform({
        type: 'filter',
        callback: (obj, index, arr) => {
          return (index + 1) / arr.length >= this.ds.state.start && (index + 1) / arr.length <= this.ds.state.end
        }
      })
      this.chart = new G2.Chart({
        container: 'chartDemo',
        forceFit: true,
        height: 270,
        animate: false,
        padding: [20, 60, 60, 40]
      })
      this.chart.legend(false)
      this.chartView = this.chart.view()
      const scales = {
        name: {
          alias: '书名'
        },
        praise: {
          alias: '好评率',
          type: 'linear',
          tickInterval: 20,
          min: 0,
          max: 100,
          minLimit: 0,
          maxLimit: 100,
          formatter: function formatter (val) {
            return val + '%'
          }
        }
      }
      this.chartView.source(dv, scales)
      this.chartView
        .interval()
        .size(40)
        .position('name*praise')
        .color('praise', '#00c55d-#4edc66')

      this.setChartViewGuide()
      this.chart.render()
    },
    updateChart () {
      let baseRate = 1
      const data = this.data
      if (data.length > BASE_LEN) {
        baseRate = BASE_LEN / data.length
      }
      this.scrollProportion = baseRate

      this.ds.getView('origin').source(data)

      let currentPos = this.ds.state.start

      // 处理边界情况
      if (currentPos + this.scrollProportion > 1) {
        currentPos = 1 - this.scrollProportion
      }
      let endPos = Math.min(1, currentPos + this.scrollProportion)
      // 处理精度误差问题
      if (1 - endPos < 1 / data.length) {
        endPos = 1
      }
      this.ds.setState('start', currentPos)
      this.ds.setState('end', endPos)

      this.$refs.scrollBar.initScroll(baseRate)
      this.chartView
        .guide()
        .clear()
      this.setChartViewGuide()
      // 让guide可以刷新,同时让tooltip刷新
      this.chart.repaint()
      this.$refs.scrollBar.initScroll(this.scrollProportion, this.ds.state.start)
    },
    setChartViewGuide () {
      if (this.average) {
        this.chartView
          .guide()
          .line({
            top: true,
            start: ['start', this.average],
            end: ['end', this.average]
          })
          .text({
            content: `平均好评率:${this.average}`,
            position: ['end', this.average],
            offsetY: -10,
            offsetX: -120
          })
      }
    },
    throttleUpdateDs: throttle(function updateDs (rate) {
      this.ds.setState('start', rate)

      let endPos = rate + this.scrollProportion
      // 处理精度误差问题
      if (1 - endPos < 1 / this.data.length) {
        endPos = 1
      }
      this.ds.setState('end', endPos)
    }, 50)
  }
}
</script>

<style lang="scss" scoped>
.chart-demo-wrap {
  position: relative;
  width: 900px;
  margin: 0 auto;
}
.scrollBarWrap {
  position: absolute;
  bottom: 0;
  left: 0;
  z-index: 10;
  width: 100%;
}
</style>

实现说明

我想对代码的一些地方进行简要的说明:

  • 重绘图表
    在实现了图表滚动后,我尝试让图表的数据刷新,以及给柱状图加上辅导线以及tooltip等,这时刷新图表,图表无法正确显示,需要先清除辅导线,然后重绘图表:
      this.chartView
        .guide()
        .clear()
      this.setChartViewGuide()
      // 让guide可以刷新,同时让tooltip刷新
      this.chart.repaint()
  • 处理精度误差问题:
    偶然情况下,我发现了一个bug:数据条数是46条,图表只能显示45条,也就是最后一条数据无法显示出来。
    这个效果是这样子的:

在这里插入图片描述
为了凸显这个错误,我在页面上显示了真实的数据长度,你们能猜到为什么会这样吗?

通过调试,我发现将滚动条拖到最后的时候,this.ds.state.start的值是0.7826086956521738,而this.ds.state.end的值是0.9999999999999999。也就是由于误差,this.ds.state.end没有被设置为1,导致最后一条数据无法显示出来,为了解决这个问题,我采用了如下方式进行处理:

 let endPos = rate + this.scrollProportion
 // 处理精度误差问题
 if (1 - endPos < 1 / this.data.length) {
   endPos = 1
 }

这里的思路就是每条数据都占了1 / this.data.length的范围,如果endPos与1的差值小于1 / this.data.length时,就可以认为endPos实际上已经是在最后的位置了。
同时,在更新图表的方法updateChart也进行了处理:

 let endPos = Math.min(1, currentPos + this.scrollProportion)
 // 处理精度误差问题
 if (1 - endPos < 1 / data.length) {
   endPos = 1
 }
  • 关于节流
    暂时自定义的滚动条组件是mousemove触发时都会发射updatePosRate事件,但是每次updatePosRate都更新canvas的话会导致页面卡顿,因此对其进行了节流,也许这里还可以更进一个,在自定义滚动条组件里也进行节流,因为视图的刷新没有mousemove触发的那么快,似乎没有必要这么频繁操作dom,但这个我还没有想好。
throttleUpdateDs: throttle(function updateDs (rate) {
  this.ds.setState('start', rate)

  let endPos = rate + this.scrollProportion
  // 处理精度误差问题
  if (1 - endPos < 1 / this.data.length) {
    endPos = 1
  }
  this.ds.setState('end', endPos)
}, 50)

其他实现的思路

一开始,我没有采用上述的方案的,尝试的是另外一个思路:利用g2容器的外层容器实现滚动:
在这里插入图片描述
实现的效果如下:
在这里插入图片描述
最终没有采用这个思路,主要原因就是往右滚动时纵坐标轴就无法显示了。

而还有一个思路,是借助chart.interaction('slider')来实现滚动,这个我觉得是可行的,不过没有进行尝试。原因主要是使用自定义的滚动组件可以实现这样一个效果,按下鼠标不松开,鼠标移出滚动条区域继续移动也能继续拖动视图变化,实现更好的效果。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值