Vue + D3 动态可视化图实现之三:排名赛跑图

GTD数据分析及可视化项目的第三张图表,项目总体介绍见这篇文章

最终效果

在这里插入图片描述
这种图经常能在B站上一些国家GDP,企业排名的可视化视频上看到,感觉效果还是比较好的。话说这种图是叫排名图?赛跑图?博主好像没找到一个统一的中文译名,英文应该主要是叫race (bar) chart。现在有挺多模版网站能自动生成这种视频,但调库虽好,作为科班出身的人,还是要有造轮子的能力啊~

实现

原数据中每个恐怖组织有一个编码,按年份统计每个组织每年的袭击次数和造成死亡人数。
在这里插入图片描述
生成时,先根据下拉框选择nkilled或attacks列,然后对数据做分帧处理。例如某组织1970年袭击数为10,1971年为20,显然我们的图不能让数字从10突然跳到20。假设分10个关键帧,那么就令一个变量k取值从0到1,每次增加0.1,每个关键帧的值为a * k + b * (1-k),这样就得到10 * 0.9 + 20 * 0.1 = 11,10 * 0.8 + 20 * 0.2 = 12…依此类推的帧,存储到keyframes数组中。每帧中只显示排名前12的恐怖组织,每个组织的柱子会给一种随机的颜色。

播放使用一个异步函数play,遍历keyframes关键帧数组,每帧对图中的四个元素进行更新:顶部的坐标轴(Axis),左边的柱子(Bar),柱子上的组织名以及右侧的数字(Label),右下角的时间(Ticker)。具体实现还是比较复杂的,这里就贴完整代码了。

<script>
	import * as d3 from 'd3';
	// 原始数据
	var rawData = []
	// 筛选袭击次数/死亡人数的数据
	var data = []
	// 展示前n名
	var n = 12
	var barSize = 40
	var margin = ({
		top: 16,
		right: 6,
		bottom: 6,
		left: 0
	})
	var height = margin.top + barSize * n + margin.bottom
	var width = 1000

	var names
	var datevalues
	var k = 10 // 原数据间插值关键帧数
	var duration = 140 // 每帧时间
	
	var gnameMap

	function rank(value) {
		const data = Array.from(names, name => ({
			name,
			value: value(name)
		}));
		data.sort((a, b) => d3.descending(a.value, b.value));
		for (let i = 0; i < data.length; ++i) data[i].rank = Math.min(n, i);
		return data;
	}

	function keyframes() {
		const keyframes = [];
		let ka, a, kb, b;
		for ([[ka, a], [kb, b]] of d3.pairs(datevalues)) {
			for (let i = 0; i < k; ++i) {
				const t = i / k;
				keyframes.push([
					new Date(ka * (1 - t) + kb * t),
					rank(name => (a.get(name) || 0) * (1 - t) + (b.get(name) || 0) * t)
				]);
			}
		}
		keyframes.push([new Date(kb), rank(name => b.get(name) || 0)]);
		return keyframes;
	}

	var nameframes
	var prev
	var next
	var svg

	var x = d3.scaleLinear([0, 1], [margin.left, width - margin.right])
	var y = d3.scaleBand()
		.domain(d3.range(n + 1))
		.rangeRound([margin.top, margin.top + barSize * (n + 1 + 0.1)])
		.padding(0.1)
		
	function procData(category) {
		data = rawData.map(d => ({
			year: d.year,
			name: d.name,
			value: d. [category]
		}))
		return data
	}

	export default {
		name: 'RaceChart',
		data() {
			return {};
		},
		methods: {
			init: function() {
				gnameMap = this.GLOBAL.gnameMap
				rawData = this.getRaceChartData()
				names = new Set(rawData.map(d => d.name))
				svg = d3.select('#race-chart-graph')
				.append("svg")
							.attr("width", width + margin.left + margin.right)
							.attr("height", height + margin.top + margin.bottom)
			},
			play: async function(data) {
				const updateBars = bars(svg);
				const updateAxis = axis(svg);
				const updateLabels = labels(svg);
				const updateTicker = ticker(svg);
				
				for (const keyframe of keyframes()) {
						const transition = svg.transition()
								.duration(duration)
								.ease(d3.easeLinear);
						// Extract the top bar’s value.
						x.domain([0, keyframe[1][0].value+500]);
						
						updateAxis(keyframe, transition);
						updateBars(keyframe, transition);
						updateLabels(keyframe, transition);
						updateTicker(keyframe, transition);
						
						await transition.end();
				}
			},
			onGenerate: function() {
				let category = d3.select(this.$el)
					.select('#category').node().value
				procData(category)
				datevalues = Array.from(d3.rollup(data, ([d]) => d.value, d => d.year, d => d.name))
					.map(([date, data]) => [new Date(date), data])
					.sort(([a], [b]) => d3.ascending(a, b))
				nameframes = d3.groups(keyframes().flatMap(([, data]) => data), d => d.name)
				prev = new Map(nameframes.flatMap(([, data]) => d3.pairs(data, (a, b) => [b, a])))
				next = new Map(nameframes.flatMap(([, data]) => d3.pairs(data)))
				this.play(data)
			}
		}
	}
	
	function bars(svg) {
		let bar = svg.append("g")
				.attr("fill-opacity", 0.6)
			.selectAll("rect");
		
		return ([date, data], transition) => bar = bar
			.data(data.slice(0, n), d => d.name)
			.join(
				enter => enter.append("rect")
					.attr("fill", (d,i) => d3.schemeTableau10[d3.randomInt(10)()])
					.attr("height", y.bandwidth())
					.attr("x", x(0))
					.attr("y", d => y((prev.get(d) || d).rank))
					.attr("width", d => x((prev.get(d) || d).value) - x(0)),
				update => update,
				exit => exit.transition(transition).remove()
					.attr("y", d => y((next.get(d) || d).rank))
					.attr("width", d => x((next.get(d) || d).value) - x(0))
			)
			.call(bar => bar.transition(transition)
				.attr("y", d => y(d.rank))
				.attr("width", d => x(d.value) - x(0)));
	}
	
	function labels(svg) {
		let label = svg.append("g")
				.style("font", "bold 12px var(--Helvetica)")
				.style("font-variant-numeric", "tabular-nums")
				.attr("text-anchor", "end")
			.selectAll("text");
	
		return ([date, data], transition) => label = label
			.data(data.slice(0, n), d => d.name)
			.join(
				enter => enter.append("text")
					.attr("transform", d => `translate(${x((prev.get(d) || d).value)},${y((prev.get(d) || d).rank)})`)
					.attr("y", y.bandwidth() / 2)
					.attr("x", -6)
					.attr("dy", "0.18em")
					.text(d => gnameMap[d.name])
					.call(text => text.append("tspan")
						.attr("fill-opacity", 1)
						.attr("font-weight", "normal")
						.attr("x", 46)
						.attr("dy", "0.15em")
						),
				update => update,
				exit => exit.transition(transition).remove()
					.attr("transform", d => `translate(${x((next.get(d) || d).value)},${y((next.get(d) || d).rank)})`)
					.call(g => g.select("tspan").tween("text", d => textTween(d.value, (next.get(d) || d).value)))
			)
			.call(bar => bar.transition(transition)
				.attr("transform", d => `translate(${x(d.value)},${y(d.rank)})`)
				.call(g => g.select("tspan").tween("text", d => textTween((prev.get(d) || d).value, d.value))));
	}
	
	function textTween(a, b) {
		const i = d3.interpolateNumber(a, b);
		return function(t) {
			this.textContent = formatNumber(i(t));
		};
	}
	
	var formatNumber = d3.format(",d")
	
	function axis(svg) {
		const g = svg.append("g")
				.attr("transform", 'translate(0,16)')
				.style('font-size', '15px');
	
		const axis = d3.axisTop(x)
				.ticks(width / 160)
				.tickSizeOuter(0)
				.tickSizeInner(-barSize * (n + y.padding()));
	
		return (_, transition) => {
			g.transition(transition).call(axis);
			g.select(".tick:first-of-type text").remove();
			g.selectAll(".tick:not(:first-of-type) line").attr("stroke", "white");
			g.select(".domain").remove();
		};
	}
	
	var formatDate = d3.utcFormat("%Y")
	
	function ticker(svg) {
		const now = svg.append("text")
				.style("font-size", '40px')
				.style("font-weight", 'bold')
				.style("font-variant-numeric", "tabular-nums")
				.attr("text-anchor", "end")
				.attr("x", width - 6)
				.attr("y", margin.top + barSize * (n - 0.45))
				.attr("dy", "0.32em")
	
		return ([date], transition) => {
			transition.end().then(() => now.text(formatDate(date)));
		};
	}
	
</script>

源码

项目总体介绍底部项目链接。本图源码为src/components/RaceChart.vue文件。

  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值