文章目录
品牌排名动态可视化 @ D3.js
任务清单
- 做出一个类似于下图所示的能根据时间更新变换的品牌动态排行榜
- 数据地址
https://gist.github.com/jrzief/70f1f8a5d066a286da3a1e699823470f
整个clone下来即可
由于多位朋友反应,这个数据集比较难下载,我把它下载好了放在了我的网盘上,大家自行下载。
在文章结尾有源代码,复制下来即可运行。
数据链接: https://pan.baidu.com/s/1VQeqk5oDTTSysPI4SHGLwg 提取码: gpve
- 要求:1.动画效果,2.各品牌的排名与数值根据年份进行修改
思路分析
- 画布初始化
- 数据预处理
- 首次数据-图元绑定,即首次data-join
- 设置时间触发器(d3.interval / setInterval)
- 编写更新函数
- 更新数据,即重新得到前12名数据
- 更新x坐标轴(因为数据值会变大,原来的坐标轴不适用了)
- 重新进行数据-图元绑定
- 年份更新
画布初始化
首先要进行画布初始化,创建初始画布svg,定义全局变量,绘制标题,副标题,坐标轴等等。
let svg = d3.select('body').select('svg');
const top_n = 12;
const margin = {
top : 80,
right : 65,
bottom : 5,
left : 20
};
let height = +(svg.attr('height'))
let width = +(svg.attr('width'))
let xAxis;
let xScale;
let yScale;
let yearText;
const barPadding = (+svg.attr('height')-margin.top-margin.bottom)/(top_n*5)
let title = svg.append('text')
.attr('class','Title')
.attr('y', 30)
.attr('x',width/2 )
.text('BrandRank')
let subTitle = svg.append('text')
.attr('y', 55 )
.attr('x', width-margin.right-70)
.attr('class', 'subTitle' )
.text("Brand value , $m")
let year = 2000;
const tickDuration = 500;//执行间隔
数据预处理
这里的数据预处理有以下几点需要注意的
注意将所有值转换为数值类型
因为d3.csv默认读进来数据都是字符串类型的,因此为了方便后续计算,要转换成数值类型
注意设置缺失值的缺省值
由于数据中有许多 NaN,集中在value字段,因此在value字段要多进行一步缺省。
注意:缺省必须在转换为数值之后
d.value = +(d.value)
d.value = isNaN(d.value) ? 0 : d.value
为每个数据设置颜色
这里比较有意思,设置颜色这步完全可以挪到data-join的时候,但是这里可以默认为某个数据绑定上一个默认的颜色,使用到的是d3.hsl()
这个接口。
d.color = d3.hsl(Math.random()*360,0.75,0.75,0.8)
这是设置完的color数据值,最后一个参数是透明度,缺省值为1。
过滤数据,仅保留当前年份的,数据切片,设置排名
-
用
data.filter()
过滤数据。然后由于绑定只需要使用到前12名数据,因此这里用到了slice()
切片函数进行了数据切片处理。let yearSlice = data.filter(d => d.year == year && !isNaN(d.value)) .sort((a,b) => b.value - a.value) .slice(0,top_n)
-
由于最终需要根据数值排名来设置矩形的位置,因此要设置一下
yearSlice
中的数据排名//此时索引就是排名了 yearSlice.forEach((d,i) => d.rank = i)
首次数据绑定
首次数据绑定我这里为了让逻辑更加清晰,封装成了一个函数render_init(yearSlice)
,而没有写在d3.csv.then()
中 ,参数是已经切分好的当前年份top12的数据。注意,render_init()
中的内容是后续需要更新的图元,由于坐标轴后续也需要更新,因此将坐标轴也放在render_init()
中
//yearSilce:切片好了的数据
const render_init = function(yearSlice){
//functionBody()
}
坐标轴
xScale = d3.scaleLinear()
.domain([0,d3.max(yearSlice,d => d.value)])
.range([margin.left , width-margin.right])
.nice()
yScale = d3.scaleLinear()
.domain([top_n, 0])
.range([height-margin.bottom, margin.top]);
xAxis = d3.axisTop(xScale)
.ticks(width > 500 ? 5:2)
.tickSize(-(height-margin.top-margin.bottom))
.tickFormat(d => d3.format(',')(d))
;
svg.append('g')
.attr('class','xAxis')
.call(xAxis)
.attr('transform',`translate(${margin.left},${margin.top})`)
矩形bar
//首次join rect
svg.selectAll('rect.bar')
.data(yearSlice,d => d.name)
.enter()
.append('rect')
.attr('class','bar')
.attr('x',xScale(0)+margin.left+2)
.attr('width', d => xScale(d.value)-xScale(0))
.attr('y', d => yScale(d.rank))
.attr('height', yScale(1)-yScale(0)-barPadding)
.attr('fill', d => d.color)
品牌名label
//首次join text,品牌名
svg.selectAll('text.label')
.data(yearSlice, d => name)
.enter()
.append('text')
.attr('class', 'label')
.attr('x', d => xScale(d.value)-8)
.attr('y', d => yScale(d.rank)+((yScale(1)-yScale(0))/2))
.attr('text-anchor','end')
.text(d => d.name)
数值
//首次join text,数值
svg.selectAll('text.valueLabel')
.data(yearSlice, d => d.name)
.enter()
.append('text')
.attr('class', 'valueLabel')
.attr('x', d => xScale(d.value)+9)
.attr('y',d => yScale(d.rank) +((yScale(1)-yScale(0))/2))
.text(d => d3.format(',.0f')(d.lastValue));
年份:标记当前是第几年的数据
//年份
yearText = svg.append('text')
.attr('class', 'yearText')
.attr('x', width-margin.right+60)
.attr('y', height-25)
.attr('font-size','2em' )
.style('font-weight', 'bold')
.style('fill', '#2eb0c5d9')
.style('text-anchor', 'end')
.html(~~year)
设置时间触发器ticker
接下来,设置触发器ticker,每隔某个间隔(这里为了看着连贯,间隔设置为500ms),就重新绑定一次数据,更新图元。
let ticker = d3.interval(e =>{
//更新部分函数
},tickDuration)
//tickDuration是更新部分函数的执行间隔
编写更新函数(重中之重)
这部分是这个程序最最关键的部分,要求思路必须非常清晰,下面我分成四个部分介绍
- 更新数据,即重新得到前12名数据
- 更新x坐标轴(因为数据值会变大,原来的坐标轴不适用了)
- 重新进行数据-图元绑定
- 年份更新
更新数据,重新获得前12名数据
这步与数据预处理中的最后一步相同
yearSlice = data.filter(d => d.year == year && !isNaN(d.value)).sort((a,b) => b.value-a.value).slice(0,top_n);
yearSlice.forEach((d,i)=>{
d.rank = i;
})
更新坐标轴
数据随着年份进行更新,数据空间的最大值改变了,因此要改变比例尺,重新映射数据。由于是数据空间改变了,因此修改domain()
即可,修改后,要用call()
坐标轴图元才会执行更新。
xScale.domain([0,d3.max(yearSlice,d=>d.value)]);
svg.select('.xAxis')
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.call(xAxis);
重新进行数据-图元绑定
重新绑定和初次绑定一样,对于矩形,品牌名,数值都要重新绑定,对于年份也要更新。这里为了方便,仅以矩形的重新绑定为例讲解思路。在数值的重新绑定中会讲一下如何实现数值的渐变
以矩形的重新绑定为例:
分为三步骤 enter(),update(),exit()
。翻译翻译就是:
- 把新出现在top12中的数据绑定上,并绘制在坐标轴中。(
enter()
) - 没有跌出top12的数据需要更新,重新绑定图元(
update()
) - 把跌出top12的数据绑定的图元清除(
exit()
)
第一步:绑定,获取update()
形态
首先,为了方便操作,首先绑定数据,绑定后获得数据的update形态
let bars = svg.selectAll('.bar')
.data(yearSlice, d=> d.name);
第二步:enter()
想要制造没有出现的矩形从下面滑上来的效果,因此y的值首先要设置在画布外面,最后通过动画缓动效果滑到坐标轴中,根据排名设置y
//enter,将尚未存在top12中的数据加入进来
//注意,transition前的y是在svg之外的,这样才会做成从下面往上浮现的效果
bars.enter()
.append('rect')
.attr('class','bar')
.attr('x',xScale(0)+margin.left+2)
.attr('width', d => xScale(d.value)-xScale(0))
.attr('y', d => yScale(top_n+1)+20)
.attr('height', yScale(1)-yScale(0)-barPadding)
.attr('fill', d => d.color)
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('y',d => yScale(d.rank))
第三步 update()
原来在画布中的矩形只需要更新它的排名(即y值),和它的宽度(即value)即可。
//第二次update,之前存在top12的数据要进行修改
bars.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('width', d => xScale(d.value)-xScale(0))
.attr('y', d =>yScale(d.rank))
第四步 exit()
退出的时候直接把y值改到画布外面即可,然后再remove()
//最后一步exit()
bars.exit()
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('y', d => yScale(top_n+1)+5)
.attr('width', d => xScale(d.value)-xScale(0)-1)
.remove()
数值渐变
在可视化中想要做成从0-100数值慢慢上涨的动画效果,需要用到tween()
和d3.interpolateRound()
,
.attr()...
.tween("textTween",function(d){
//做出在两个value间跳动的效果
let i = d3.interpolateRound(d.lastValue,d.value);
return function(t){
this.textContent = d3.format(',')(i(t));
};
其他的代码贴在最后,与矩形的更新相同
年份更新
这里很有意思,直接贴代码。
值得一提的是,为了让年份慢慢更新,每次只加0.1
yearText.html(~~year)
//~~year:等于向上取整,2018.1 取反等于 -2019, 再次取反等于2019
if(year == 2018){
ticker.stop();
}
year = d3.format('.1f')(+(year)+ 0.1)
完整代码
<!DOCTYPE html>
<html>
<head>
<title>brandRank</title>
<script src="./js/d3.min.js"></script>
<style>
text.Title{
font-size: 2em;
font-weight: 500;
}
text.subTitle{
font-weight: 500;
fill: #777777;
}
text.label{
font-weight:bold;
}
</style>
</head>
</head>
<body>
<svg width="960" height="600"></svg>
<script>
let svg = d3.select('body').select('svg');
const top_n = 12;
const margin = {
top : 80,
right : 65,
bottom : 5,
left : 20
};
let height = +(svg.attr('height'))
let width = +(svg.attr('width'))
let xAxis;
let xScale;
let yScale;
let yearText;
const barPadding = (+svg.attr('height')-margin.top-margin.bottom)/(top_n*5)
let title = svg.append('text')
.attr('class','Title')
.attr('y', 30)
.attr('x',width/2 )
.text('BrandRank')
let subTitle = svg.append('text')
.attr('y', 55 )
.attr('x', width-margin.right-70)
.attr('class', 'subTitle' )
.text("Brand value , $m")
let year = 2000;
const tickDuration = 500;//执行间隔
const render_init = function(yearSlice){
xScale = d3.scaleLinear()
.domain([0,d3.max(yearSlice,d => d.value)])
.range([margin.left , width-margin.right])
.nice()
yScale = d3.scaleLinear()
.domain([top_n, 0])
.range([height-margin.bottom, margin.top]);
xAxis = d3.axisTop(xScale)
.ticks(width > 500 ? 5:2)
.tickSize(-(height-margin.top-margin.bottom))
.tickFormat(d => d3.format(',')(d))
;
svg.append('g')
.attr('class','xAxis')
.call(xAxis)
.attr('transform',`translate(${margin.left},${margin.top})`)
//首次join rect
svg.selectAll('rect.bar')
.data(yearSlice,d => d.name)
.enter()
.append('rect')
.attr('class','bar')
.attr('x',xScale(0)+margin.left+2)
.attr('width', d => xScale(d.value)-xScale(0))
.attr('y', d => yScale(d.rank))
.attr('height', yScale(1)-yScale(0)-barPadding)
.attr('fill', d => d.color)
//首次join text,品牌名
svg.selectAll('text.label')
.data(yearSlice, d => name)
.enter()
.append('text')
.attr('class', 'label')
.attr('x', d => xScale(d.value)-8)
.attr('y', d => yScale(d.rank)+((yScale(1)-yScale(0))/2))
.attr('text-anchor','end')
.text(d => d.name)
//首次join text,数值
svg.selectAll('text.valueLabel')
.data(yearSlice, d => d.name)
.enter()
.append('text')
.attr('class', 'valueLabel')
.attr('x', d => xScale(d.value)+9)
.attr('y',d => yScale(d.rank) +((yScale(1)-yScale(0))/2))
.text(d => d3.format(',.0f')(d.lastValue));
//年份
yearText = svg.append('text')
.attr('class', 'yearText')
.attr('x', width-margin.right+60)
.attr('y', height-25)
.attr('font-size','2em' )
.style('font-weight', 'bold')
.style('fill', '#2eb0c5d9')
.style('text-anchor', 'end')
.html(~~year)
}
d3.csv("./data/brand_values.csv").then(data =>{
//数据预处理
data.forEach(d => {
d.lastValue = +d.lastValue
d.value = isNaN(d.value) ? 0 : +(d.value)
d.year = +d.year
d.rank = +d.rank
d.color = d3.hsl(Math.random()*360,0.75,0.75,0.8)
})
console.log(data);
let yearSlice = data.filter(d => d.year == year && !isNaN(d.value))
.sort((a,b) => b.value - a.value)
.slice(0,top_n)
//此时索引就是排名了
yearSlice.forEach((d,i) => d.rank = i)
render_init(yearSlice);
let ticker = d3.interval(e =>{
//重新切分数据
yearSlice = data.filter(d => d.year == year && !isNaN(d.value)).sort((a,b) => b.value-a.value).slice(0,top_n);
yearSlice.forEach((d,i)=>{
d.rank = i;
})
//对x轴进行重新映射
xScale.domain([0,d3.max(yearSlice,d=>d.value)])
//x轴随着图元的值修改
svg.select('.xAxis')
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.call(xAxis)
//第一步:获取update部分
let bars = svg.selectAll('.bar')
.data(yearSlice, d=> d.name);
//enter,将尚未存在top12中的数据加入进来
//注意,transition前的y是在svg之外的,这样才会做成从下面往上浮现的效果
bars.enter()
.append('rect')
.attr('class','bar')
.attr('x',xScale(0)+margin.left+2)
.attr('width', d => xScale(d.value)-xScale(0))
.attr('y', d => yScale(top_n+1)+20)
.attr('height', yScale(1)-yScale(0)-barPadding)
.attr('fill', d => d.color)
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('y',d => yScale(d.rank))
//第二次update,之前存在top12的数据要进行修改
bars.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('width', d => xScale(d.value)-xScale(0))
.attr('y', d =>yScale(d.rank))
//最后一步exit()
bars.exit()
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('y', d => yScale(top_n+1)+5)
.attr('width', d => xScale(d.value)-xScale(0)-1)
.remove()
//labels与bars相同
let labels = svg.selectAll('.label')
.data(yearSlice,d => d.name)
labels.enter()
.append('text')
.attr('class', 'label')
.attr('x', d => xScale(d.value)-8)
.attr('y', d => yScale(top_n+1)+20)
.attr('text-anchor','end')
.text(d => d.name)
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('y', d => yScale(d.rank)+((yScale(1)-yScale(0))/2) +1)
labels.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('x',d => xScale(d.value) -8 )
.attr('y', d => yScale(d.rank)+((yScale(1)-yScale(0))/2)+1)
labels.exit()
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
// .attr('x', d => xScale(d.value) -8 )
.attr('y',d => yScale(top_n+1)+20 )
.remove()
//valueLabels与bars相同
let valueLabels = svg.selectAll('.valueLabel')
.data(yearSlice,d => d.name)
valueLabels
.enter()
.append('text')
.attr('class', 'valueLabel')
.attr('x', d => xScale(d.value)+9)
.attr('y',d => yScale(top_n) +20)
.text(d => d3.format(',.0f')(d.lastValue))
//enter进来的时候用lastvalue,后面update的时候再慢慢增加至value
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('y', d => yScale(d.rank) +((yScale(1)-yScale(0))/2) );
//update加一点数值渐变效果
valueLabels
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('x', d => xScale(d.value)+9)
.attr('y', d => yScale(d.rank) +((yScale(1)-yScale(0))/2) )
.tween("textTween",function(d){
//做出在两个value间跳动的效果
let i = d3.interpolateRound(d.lastValue,d.value);
return function(t){
this.textContent = d3.format(',')(i(t));
};
});
valueLabels.exit()
.transition()
.duration(tickDuration)
.ease(d3.easeLinear)
.attr('x', d => xScale(d.value)+9)
.attr('y', d => yScale(top_n+1)+20)
.remove()
yearText.html(~~year)
//~~year:等于向上取整,2018.1 取反等于 -2019, 再次取反等于2019
if(year == 2018){
ticker.stop();
}
year = d3.format('.1f')(+(year)+ 0.1)
},tickDuration);//end of tick
});//end of d3.csv.then()
</script>
</body>
</html>