1

公司项目要求做动态条形图,也就是Bar chart race,本来想从网上找个demo发觉没有合适的,就自己写了一个,完整代码可以去 我的GitHub 上查看。动态条形图可以很好地对比多个数据随之间变化的趋势。该demo是基于 d3v6 版本,由于仅使用了一些最基础的 api,所以在之前版本应该也可以跑起来。本文章仅提供一种实现的思路,不对 api 和“进入、更新、退出”模式作详细讲解,所以在阅读之前最后对它们有一个最基本的了解。
首先需要找一个合适的数据源,这里我就从网上随便找了一份。

初始化变量

const width = 1200, height = 600, margin = { top: 20, bottom: 0, left: 50, right: 80 };
const chartWidth = width - (margin.left + margin.right), chartHeight = height - (margin.top + margin.bottom);
const data = [];
const count = 10;
const duration = 500;
const barPadding = 20;
const barHeight = (chartHeight - (barPadding * count)) / count;
const getDate = () => dataOri[0][dateIndex];
let dateIndex = 1;
let date = getDate();
let dataSlice = [];
let chart = null, scale = null, axis = null, svg = null, dateTitle = null;

首先设定长、宽、外边距,和图表尺寸。data 存放格式化后的数据。由于图表不可能把数据源中所有的行都显示出来,所以这里只取前 10 个。每隔 10 秒切换一纵列。柱间距为 20 。用图表高度减去柱间距乘以柱数量再除以柱数量得每个柱的宽。定义一个函数来获取当前列表头,这里就是日期,赋给 date。定义 dataSlice 来存放当前日期下的所有数据。最后定义 chart 存放图表实例,scale 存放比例尺,axis 存放坐标轴,svg 存放画布,dateTitle 存放当前列表头。

const createSvg = () => svg = d3.select('#chart').append('svg').attr('width', width).attr('height', height);

创建一个 svg 设置宽高并 append 到预先写好的 container 中。

格式化数据

function randomRgbColor() {
  const r = Math.floor(Math.random() * 256);
  const g = Math.floor(Math.random() * 256);
  const b = Math.floor(Math.random() * 256);
  return `rgb(${r},${g},${b})`;
}

先声明一个创建随机颜色的函数,用来给条形上色。
image.png
我们的数据源是这种形式的,需要对其进行简单的格式化,成为一个一个条目

const formatData = () => {
  dataOri[0].forEach((date, index) => {
    if (index > 0) {
      dataOri.forEach((row, rowIndex) => {
        if (rowIndex > 0) {
          data.push({
            name: row[0],
            value: Number(row[index]),
            lastValue: index > 1 ? Number(row[index - 1]) : 0,
            date: date,
            color: randomRgbColor()
          });
         }
      });
    }
  });
}

两层循环,第一层循环列,第二层循环行,存入行表头,数据,上一列的数据,如果没有就写 0,列表头,和一个随机的颜色,用作给条形图上色,至于 lastValue 的用途之后用到了会详细解释。
格式化后的数据如图所示:
image.png

const sliceData = () =>
  dataSlice = data.filter(d => d.date === date).sort((a, b) => b.value - a.value).slice(0, count);

筛选出当天的数据,倒叙排列并取前 10 个。

创建坐标轴

const createScale = () =>
  scale = d3.scaleLinear().domain([0, d3.max(dataSlice, d => d.value)]).range([0, chartWidth]);

首先把比例尺创建出来,定义域是 0 到当天的最大值,值域是 0 到图表宽度,对 d3.jsapi 不熟悉的同学可以去官网补习一下或者自行百度,常用的基本就那么几个。
坐标轴的最终效果如下图所示:
image.png

需要对坐标轴进行简单的配置

const renderAxis = () => {
  createScale();
  axis = d3.axisTop().scale(scale).ticks(5).tickPadding(10).tickSize(0);
  svg.append('g')
    .classed('axis', true)
    .style('transform', `translate3d(${margin.left}px, ${margin.top}px, 0)`)
    .call(axis);
}

调用之前定义的比例尺函数创建比例尺,然后设置顶部的坐标轴,ticks 设置 5 个刻度(这个方法比较有意思,虽然设置了5,但是不一定真的是 5,可能比 5 多也可能比 5 少),tickPadding 设置刻度与数值之间的间距,tickSize 设置刻度线长度,这里不让它显示。设置完成之后 append 到图表中,水平位移,让出边距的位置。

创建参考线
image.png
这条竖线就是参考线,从坐标轴刻度延伸出来,贯穿整个图表。

const renderAxisLine = () => {
  d3.selectAll('g.axis g.tick').select('line.grid-line').remove();
  d3.selectAll('g.axis g.tick').append('line')
    .classed('grid-line', true)
    .attr('stroke', 'black')
    .attr('x1', 0)
    .attr('y1', 0)
    .attr('x2', 0)
    .attr('y2', chartHeight);
}

由于随着数据的变化,参考线是不断变化的,该函数会被反复调用,所以要在一开始清除上一组数据的参考线。然后在坐标轴每一个有刻度线的位置都 append 一条线进去,x1y1 是该条线相对于父元素的左端点,x2y2 是右端点,由于要贯穿整个图表,所以右端点的 y 坐标设置为 chartHeight

创建列表头
image.png
图表右下键这个日期,也就是列的表头

const renderDateTitle = () => {
  dateTitle = svg.append('text')
    .classed('date-title', true)
    .text(date)
    .attr('x', chartWidth - margin.top)
    .attr('y', chartHeight - margin.left)
    .attr('fill', 'rgb(128, 128, 128)')
    .attr('font-size', 40)
    .attr('text-anchor', 'end')
}

位移至右下角,设置颜色。这里重点说一下 text-anchor,主要运用在 svg<text> 标签的一个属性,设置文本的对其方式,设置为 end 表示文本字符串的末尾即当前文本的初始位置。

创建图表主体

const createChart = () => {
  chart = svg.append('g')
    .classed('chart', true)
    .style('transform', `translate3d(${margin.left}px, ${margin.top}px, 0)`);
}

创建一个容器存放多个条形,并移到正中央。

const renderChart = () => {
  // 进入、更新、退出模式
}

该函数存放“进入、更新、退出”模式的代码,由于该模式可以单独引申出一篇文章去讲解,不太了解的同学还是建议先去自行理解一下。

const bars = chart.selectAll('g.bar').data(dataSlice, (d) => d.name);
let barsEnter;
barsEnter = bars.enter()
  .append('g')
  .classed('bar', true)
  .style('transform', (d, i) => `translate3d(0, ${calTranslateY(i)}px, 0)`);
dateIndex > 1 && barsEnter
  .transition().duration(this.duration)
  .style('transform', (d, i) => `translate3d(0, ${calTranslateY(i, 'end')}px, 0)`);
barsEnter.append('rect')
  .style('width', d => scale(d.value))
  .style('height', barHeight + 'px')
  .style('fill', d => d.color);
barsEnter.append('text')
  .classed('label', true)
  .text(d => d.name)
  .attr('x', '-5')
  .attr('y', barPadding)
  .attr('font-size', 14)
  .style('text-anchor', 'end');
barsEnter.append('text')
  .classed('value', true)
  .text(d => d.value)
  .attr('x', d => scale(d.value) + 10)
  .attr('y', barPadding);

将图形与 dataSlice 绑定,barsEnter 代表的是绑定了数据的图形,设置它的宽,高和颜色,条形左侧的 y 轴,这里对应的国家的名字,还有右侧数值标注。这里用到了一个工具函数:

const calTranslateY = (i, end) => {
  if (dateIndex === 1 || end) {
    return (barHeight + barPadding) * i + (barPadding / 2);
  } else {
    return (barHeight + barPadding) * (count + 1);
  }
}

当数据为第一列或者传入 end 的时候条形的纵轴位置在排序所在的位置,否则都放在图表外面,等待进入。

bars.transition().duration(duration).ease(d3.easeLinear)
  .style('transform', function (d, i) {
    return 'translate3d(0, ' + calTranslateY(i, 'end') + 'px, 0)';
 })
  .select('rect')
  .style('width', function (d) {
    return scale(d.value) + 'px';
 });
bars
  .select('text.value')
  .transition().duration(duration).ease(d3.easeLinear)
  .attr('x', function (d) {
    return scale(d.value) + 10;
 })
  .tween('text', function (d) {
    const textDom = this;
    const i = d3.interpolateRound(d.lastValue, d.value);
    return (t) => textDom.textContent = i(t);
 });

更新模式,第一个方法链目的是条形按照顺序排序,并且根据数值设定宽度。第一个方法链是设定右面标注的数值,并且自定义了一个数值过渡,让数值的增长没有那么生硬,这里用到了一开始格式化数据的时候设置的 lastValue

bars.exit()
  .transition().duration(duration).ease(d3.easeLinear)
  .style('transform', function (d, i) {
    return 'translate3d(0, ' + calTranslateY(i) + 'px, 0)';
 })
  .style('width', function (d) {
    return scale(d.value) + 'px';
 })
  .remove();

退出模式,将退出后的条形移到屏幕外并删除。

调用方法

const init = () => {
  createSvg(); // 创建一个svg
  formatData(); // 格式化数据
  sliceData(); // 截取当天数据
  renderAxis(); // 渲染坐标轴
  renderAxisLine(); // 渲染指示线
  renderDateTitle(); // 渲染日期
  createChart(); // 创建图表
  renderChart(); // 渲染图表
  createTicker(); // 创建定时器
}
init();

依次调用一开始声明的那些方法,还有最后一个 createTicker 方法没有声明

function createTicker() {
  const ticker = d3.interval(() => {
    if (dateIndex < dataOri[0].length - 1) {
      dateIndex++;
      date = getDate();
      dateTitle.text(date);
      sliceData();
      updateAxis();
      renderAxisLine();
      renderChart();
    } else {
      ticker.stop();
    }
  }, duration);
}

创建了一个定时器,每隔 duration 设定的事件进行切换,更新坐标轴、辅助线、图表等,这里用到了 updateAxis 方法。

const updateAxis = () => {
  createScale();
  axis.scale().domain([0, d3.max(dataSlice, d => d.value)]);
  svg.select('g.axis')
    .transition().duration(duration).ease(d3.easeLinear)
    .call(axis);
  d3.selectAll('g.axis g.tick text').attr('font-size', 14);
}

该方法用于当数据改变时更新坐标轴。

总结
动态条形图的所有功能都已经开发完了,打开页面就可以看到动画效果了。完整代码可以我的 GitHub 中下载。其实该图表算是 d3 比较入门的效果,掌握了“进入、更新、退出”模式和过渡之后就可以开发出来了。本文提供的思路也并非该图表实现的最优解,如有更好的实现方法欢迎留言讨论。


秦老爷子
134 声望13 粉丝