示例项目地址: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')
来实现滚动,这个我觉得是可行的,不过没有进行尝试。原因主要是使用自定义的滚动组件可以实现这样一个效果,按下鼠标不松开,鼠标移出滚动条区域继续移动也能继续拖动视图变化,实现更好的效果。