(TODO)技术拆解-时间轴柱状图

最近业务方有一个需求,需要一个展示排名变化的时间轴柱状图。之前我们用网上一个开源的代码做过类似的功能,但是当时只是为了满足一次性需求,代码写得非常的定制化,不够通用。考虑到这种时间轴动态图的需求应该后续还有不少,因此这次我准备借助这个需求,在这个开源项目的基础上进行代码优化和封装,使其更加灵活、更加配置化、支持的功能更加丰富。

基本信息

项目是在一个开源项目基础上加工而成的,技术方面采用了 D3.js

这里有个魔改项目,有一些程序说明信息:https://github.com/TangliziGit/ColumnsAnimation,仅用200多行代码就实现了大部分核心功能,可以参考下。

(TODO)这里有个 Observer 上的 D3 作者写的基于Scrubber的示例,可以好好琢磨下:https://observablehq.com/@mbostock/bar-chart-race-with-scrubber

技术点

D3.js、SVG

把这几个属性精通后,动画就比较好弄了吧:

transform, x, y, width, height, fill-opacity, class(带有颜色类), text, stroke-width

数据结构

这个项目的数据结构比较简单,就是普通的柱状图数据结构,再加上时间属性,这里就略过了。

页面元素拆分

模块 元素 交互 动画 实现方案

SVG 元素的结构

摘自魔改项目作者的说明:

barEnter就是柱子,可以重点看一下这个对象相关的设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
g group标签包含其他所有svg标签
g x轴(由d3生成)
text x轴标签(d3生成)
path x轴(d3生成)
g*n n个时间竖线(英文是Tick不知道怎么翻译)
line 竖线
text x轴数字
g 这个组不知道干嘛的,可能是y轴,以后考虑去掉?
text 这个是timer,改源代码时忘记留下来了,最后自己加上的
g*n n个bar,这是动态的主题
g 这是标记颜色的,好像没用?
rect 这是柱子,只有颜色类
text y轴上的柱后文字,label类颜色类
text 柱前数字,value类颜色类
text 柱上文字,barInfo类

流程图

  • 请求数据

  • 整理数据(排序、格式化、转为 key-value 等等)

  • 每一帧中:

  • 算出当前需要展示的数据、下一次需要展示的数据

  • 根据上一步的内容,设置 transition 的动画参数

动画

横向延长柱子

这个应该容易,变更数据即可。

可以查看 barUpdate 这个对象,这就是需要更新数据的柱子。

是怎么对这些柱子分组的?即如何将其标注为需要更新?

柱子上下翻飞

算出下一个位置的排行,根据行高调整 y 的数值?

通过 change()这个函数,计算上下偏移的数值,然后通过transform来实现的:

1
2
3
4
5
6
7
8
9
10
11
12
this.container
.selectAll('.bar')
.data(this.currentData, function (d) {
return d.name;
})
.transition('1')
.duration(
this.option.baseTime * this.option.update_rate * this.option.interval_time
)
.attr('transform', function (d) {
return 'translate(0,' + that.yScale(that.yValue(d)) + ')';
});

代码总结

很多代码其实是设置样式,是可以独立出来的。

配置项也可以做个层级拆分,不要只是一级。

代码没有做函数封装,基本上一个流水账直接下来。

我对于 D3 和 SVG 画基础的柱状图的元素,还不是很熟悉。

1
2
3
4
5
6
7
8
9
// 动画?
xAxisG
.transition()
.duration(baseTime * interval_time)
.ease(d3.easeLinear)
.call(xAxis);

// transition的参数值似乎只是个标志而已;好像是被用来作为选择元素的一个操作了,即选中赋予了动画2的元素,然后对这些元素执行redraw操作
d3.transition('2').each(redraw).each(change);

更改记录

25 号下班前提供一版,包含核心功能。

27 号再提供一版,包含所有功能。

把每个作图模块拆分开

工欲善其事,必先利其器。

一定要注意页面元素初始化的顺序,这个也可以弄个经验流程图

感觉要坑,这个拆分远比我想象的耗时。先只拆分我需要修改的几项吧,其他的靠后拆分。

基本上每一项拆分都要耗费 1 个小时,远比我想象的慢;不过后面越来越快了,形成套路就好了。

画个模块拆分和命名的图,一图看懂概念和组件设计!

BUG-提前一点

内存泄漏

刚开始是 140M 左右,后面会升到 300+M;动画播放完毕后,静止状态内存仍然会缓慢上升,可以重点看下 loop 里面的逻辑。另外该释放的变量就主动释放掉。

如何快速定位内存泄漏问题?可以单独写个经验博客。

(Done)当前日期动画没完成,点跳转某个日期,会出现白色柱子

播放慢也是跳转日期,白色柱子也是跳转日期,这个肯定有问题。

是因为 playByDate 主动调用了 getCurrentData,把循环打乱了么?loop 里面会调用 getCurrentData 的;应该全部都统一用 loop,不能手动调用 getCurrentData

但是我得有一个立即触发下一步动画的方法,即修改上一次的执行时间大于动画 interval,让下一次 loop 立刻执行。

【Warning】我这边有个偶现 bug,就是动态修改数据之后调用重绘,会出现图显示不出来的问题,但是左上角的 Y 轴单位可以正常显示

我看此时 svg 里面 g 的大小为 0*0

调用 resume 可以修复,那 resume 干了什么事情?

resume 就是修改了 playable,是因为这个在有些情况下没被重新设置的缘故么?

(Done)重头播放经常出问题,白色柱子

是因为同时调用 loop 冲突了么?

playByDate 里面调用了 getCurrentData

是因为当前日期的动画没有完成,就开始调用 resume 了;

得搞个动画的回调函数,我现在加了这个,还是有问题,会停顿一会

得有个清空当前计数器,直接开始的功能

动画过程中点击暂停,出现过左侧 title 文字重叠的问题

(Done)切换浏览器 tab 页后,动画会乱掉

感觉是 setInterval 在切换后仍然在跑,但是动画并未画图的缘故。

可能改为 requestAnimationFrame 可以解决?

在播放过程中点击跳到某一个日期,左侧 title 文本会重叠

(Done)跳转某个日期后,点恢复,要等一阵才播放动画

看日志,是重新播放了一遍当前日期,比如 2015

交互稿的样式-乐观估计 10 小时,加上代码优化,起码两天

样式还原还有很多地方要弄,高风险啊

我 TM 太乐观了,瞎估

组员感觉不爽是有原因的。

实际上周六我花了 6 小时,周日花了 5 小时,勉强将业务需求搞定了;优化和组件抽取还没搞。

【精】动画的优化

动画应该分为两部分:

1、页面元素的切换动画

2、操控到下一个日期的动作,这个通过 requestAnimationFrame 来实现会更好一些,可以从动画里面独立出去

动画也可以分为:

1、无缝动画,即一直播放

2、有缝动画,即播完当前日期的动画,停顿一会,再播放下一个日期

这一块可以抽取出来形成一个套路和组件

左侧有一根竖线

自定义功能?缩放可能出问题

还是集成到作图组件中,配置成可显示吧

还原设计稿

(Done)顶部坐标轴的刻度文字,需要能支持设置偏移量

开放设置字体的配置项

(Done)tick 的颜色需要能够设置

(Done)自定义渐变颜色列表

有问题,如何保证相邻的 2 个柱子一定不会取到同一个渐变色

(Done)用户需求

1、点击柱子之后需要去更改标题名称,是不是要开放一个回调或者暴露出一个全局变量
2、当前自然播放结束状态,柱子不可点击,我看自然播放结束之后 playable 依然是 true,所以点击事件 return 了
3、提供的数据自然播放结束会出现两个白色的柱子,还有三个柱子没有国家名称的,看下

4、根据当前播放状态现实操作按钮,所以需要一个全局变量或者一个 api 获取当前状态(播放中,暂停中,播放结束)

5、有没有页面重绘的方法,现在数据修改了,需要组件重新绘制

需要注意别绑了 2 次 loop;还有各个元素别初始化了 2 次

这就是为什么每个小组件都必须有 init 方法和 destroy 方法,且 init 中需要先清空现有的组件,即调用 destroy 方法

等等,不用一个个清空,直接整个清空就可以了啊

自定义每个柱子的颜色

根据 ID 做唯一标记,不可重复

圆角四个角自定义

导出 gif 和视频的功能

resize 事件

(Done)右下角自定义标签-从 text 改为 div

回调返回的数据不够,需要返回排名截取前 N 之前的、当前日期所有的数据

思考:这种和图形独立的元素,是否直接用普通的 DOM 元素更好?比如直接用 DIV。

这样对于样式、定位,都会更加方便可控一些。

(TODO)如何设置相对位置呢?不设置固定的数值,直接右对齐、下对齐,否则数字长度变更后,会超出界面范围

(Done)点击柱子的事件

注意:仅在动画没有播放的时候才支持点击事件。

选中的柱子高亮、其他柱子淡化,得记住每根柱子 hover 之前的渐变色才行;也不用吧,按照顺序来?还是绑定到柱子的属性上?

且只能在静止状态点击吧?

还得有回调函数,选中的柱子的数据、年份

(Done)鼠标 hover 柱子时出数据浮层(tooltip)-我优先级弄错了,花了大半天做了一个优先级低的功能

这个 tooltip 也需要能够自定义样式,完全开放出去吧?

(Done)可以抽取 div 自定义的配置规范和 d3 的 customDiv 函数

(Done)跳到指定某个日期的数据

这种情况得去掉动画吧

这个是高风险点

将 intervalTime 从 1 改为 0.001 就可以了,这样动画都是毫秒级别的,用户就感知不到动画了。

有时候感觉很难的事情,换个思路,就变简单了。

(Done)默认一进来就可以初始化到某个日期

new DynamicHistogram(el, data, option)

比如默认进来展示最后一个日期的静态图

为什么默认进来有个空白期间?因为第一个播放,走了 setTimeout 这个逻辑,进行了延时

(Done)样式冲突问题

把这个给忽略了

全部样式加上前缀吧

(Done)柱子之间的间隙、柱子的宽度做成配置化

将一个柱子的元素放入一个 g 中:本身就是放入了一个 g 了

宽度可以配置,但是还需优化,文字没有自动对齐

柱子之间的空隙是自动根据画布高度计算的

(Done)提供配置文档给对方开发

先只提供页面需要的那部分

(Done)回调里面把全量数据的引用返回

(Done)横坐标放到顶部

可以由用户配置方向和具体位置吧

top、right

position

坐标轴出问题了,好像是用了对数轴??

(Done)柱子、文字的大小、样式配置独立出来

搞一个通用的设置文本样式的组件函数

(Done)渐变色-线性还是?

从左到右,和我现在的不一致

需要开放数组给对方配置

(Done)柱子右侧文本的格式自定义

需要开放函数出来

图例 legend-这期先不加

可以控制显示隐藏

用了渐变色,需要考虑下图例的颜色可能不具备辨识度了

设计师:因为我们的图支持转为静态图的,所以图例这种东西应该需要给到

时间轴

业务方自己做?

应该我们做,并且做成一个通用的时间轴,单独的一个组件

需要支持拖拽

写配置文档,提供 demo

静态图

可以选中某个柱子

(Done)发布到我的服务器测试下

D3.js 放到本地

优化:上下翻动的时候,文字样式可以先淡化,后加浓,避免重叠的时候很难看

优化:性能问题,我的手机居然卡

优化

项目不在于多,而在于精。

改为符合 ESLint 规范的格式

之前居然忘记安装 ESLint 了

Sonar 检查

有 eslint, tslint 等工具,还要 sonar 干嘛:
首先需要说的是,这两者不是一个层级的东西,eslint, tslint 是 js 代码,ts 代码的风格检查工具,其定义一些代码编写风格,主要通过这些风格规范个人的代码。

而 sonar 是一个代码质量管理平台,其支持多种语言,多种检查工具,并将这些工具的结果统一化展示,比如对于 js,ts 代码,sonar 就有 eslint,tslint 等的插件可以集成进去,统一检查。

改写为 TypeScript

用 Airbnb 的插件先自动转一次

整理下这两天写组件的收获

流程、套路、规范、技巧等等
该怎么写文档呢?也给个规范吧;先看看之前我们整理的 confluence 上的文档规范

怎么还原 UI 稿子

怎么在开发过程中,方便的跟业务方对接?

我这边会持续修改配置,每次都让业务方调整,很麻烦的

组的设计:将柱子及相关的文字放到一个组中,方便操控动画

动画的操控不应该散落在各个小模块中,必须集中管理。

浏览器兼容性测试

可以支持暂停、播放、直接显示最终排名、显示某个指定日期的排名等

缺少:Legend

需要支持点击后显示、隐藏某些数据

配置顺序重新规划下

最常用到的配置提到前面来

动画参数写死了

注意:这个得优先抽离出来,不然要调死了,代码中散落很多。

需要改为配置,暴露出来。

顶部横坐标

动画的播放从 setInterval 改为 requestAnimation

多图叠加(底部折线图)

BUG:左侧文字过长,会往下掉

好像不是这个问题,待确认

logo 自定义

可以是文字,也可以是图片,目前已经支持了

足够多的 Demo

参考 ZHQ 的 Demo 页,专门搞一个页面展示各种配置出来的效果。

只专注于柱状图,其他的文字都通过自定义回调函数实现

命名全部改为驼峰,且做到见文知意

技巧

x、y、dx、dy

dx 和 dy,和 x、y 类似,除了它们的值表示的是相对于前一个字符的长度,而不是相对于整个视窗的绝对定位。

bind 的用法

组件内的 DOM 元素的定位

建议 position 都用 relative,不然就得给组件外层的 div 设置 position 属性了,不便于应用方自己控制这个 div 的位置。

一个技巧:子绝父相,套 DIV

为什么 bar 左侧的 title 文本不显示了?

看来我还是没把他画柱子的逻辑完全弄明白。

1
2
3
4
5
6
7
update() {
var bar = this.bar = this.container
.selectAll('.' + generateClassName('bar'))
.data(state.currentData, function (d) {
return d.name;
});
let that = this;

这里本来是 state.container,我换成 this.container 就不行了。

看下transition()的 API,弄清楚具体是什么。

是因为对象的缘故吧?这个也是有范围的,绑定在哪个对象上,就只在这个对象下面的范围内查询;而我是放在了一个 g 下面,在另外一个文件中,是直接通过 d3.transition(‘2’)查的,就导致查不到?

错了,是我初始化元素的时候写错了,把 g 写成了 svg:

1
this.container = state.container.append('g');

SVG 元素和 HTML 元素的样式设置存在差异

比如设置字体,针对 SVG 元素,这样是可以的:

1
d3Object.style('font-size', 12);

但是针对 HTML 元素就不行了,必须写明单位,比如 px:

1
d3Object.style('font-size', 'px');

其他还有更多差异待探索,感觉应该带单位的都是不兼容的;不带单位的,比如 opacity 这种,是兼容的。

另外还有一些特殊的属性,比如fill,针对 SVG 的 text 是生效的,但是针对普通 HTML 中的文本是不生效的。这部分还有待尝试探索。

注意:还有一个比较特殊的,就是坐标轴的刻度文本,颜色不能通过 fill 设置,得通过 color 设置。

d3.selectAll 不能用数字作为样式名首字母

1
2
3
4
5
// 报错:Uncaught DOMException: Failed to execute 'querySelectorAll' on 'Document': '.3c6afc8d6c39ad8bar' is not a valid selector.
d3.selectAll('.3c6afc8d6c39ad8bar');

// 正确
d3.selectAll('.c6afc8d6c39ad8bar');

通过 d3.event 对象获取事件处理相关的信息

比如当前触发事件的坐标位置

比如 tooltip,一般用 d3.event.pageX 和 d3.event.pageY

坐标轴设置样式

还是没搞明白,这个太危险了,一定要抽时间完全摸透!

为什么我的 Y 轴的刻度线画不出来??

1
2
3
4
5
6
7
8
// 线条和文字的颜色
.style('color', 'red')
// 字体大小
.style('font-size', '20')
// 填充
.style('fill', 'red')


注意:d3 画坐标轴的时候,会默认加上一个 class 为 domain 的 path 元素:

1
<path class="domain" stroke="currentColor" d="M-6,0.5H0.5V750.5H-6"></path>

如果想要设置该元素的样式,可以参考 d3 作者的回答,用post-selection

https://github.com/d3/d3-axis/issues/48

1
2
3
g.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(8, '$.0f'))
.call((g) => g.select('.domain').remove());

观察者模式

参考这里的 updateMaterial 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 创建一个节点的mesh
function getNodeMesh(node, nodeOpt, id, that) {
//创建一个球体几何对象 半径,横向分割数,纵向分割数
let geometry = new THREE[nodeOpt.geometry](...nodeOpt.geometryArgs);
//材质对象Material
let material = new THREE[nodeOpt.material](nodeOpt.materialOption);
//网格模型对象Mesh
let mesh = new THREE.Mesh(geometry, material);

mesh.name = 'node_' + id;
// 设置位置
mesh.position.set(...node.position);

// 更新节点材质
mesh.updateMaterial = function (material, render = true) {
this.material = material;
render && that.render();
};

return mesh;
}

css 的样式优先级高于后续设定的样式

比如这里设置的.value,优先级就高于下面设置的 style 中的 font-size

1
2
3
barUpdate.select('.value').attr('class', function (d) {
return 'value';
});
1
d3Object.style('font-size', style.fontSize);

局部圆角

https://stackoverflow.com/questions/12115691/svg-d3-js-rounded-corner-on-one-corner-of-a-rectangle/12124192#12124192

得用 path,比较麻烦,这次就算了吧

代码拆分

先重构成 class

把 draw 改为 class,其他的先扔到 init 方法里面去?

function 先抽离出来

d3 的回调中无法使用 this 的问题

这次用到了三种方式来解决这个问题.

在回调之前缓存 this 变量

1
let that = this;

采用全局唯一的变量来挂载数据

这次我是用了一个单独的 state 模块来挂载数据,这个有点类似 Vuex 的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const state = {
// 作图元素
el: null,
// 组件元素的属性、样式、id的前缀
prefix: 'dynamic_histogram_label_',
// 上一次动画执行的时间戳
previousExecuteTime: null,
// 当前日期的全量数据
currentFullData: null,
// 当前展示的日期
currentDate: null,
};

export { state };

这种方式其实并不好,适合用于具体的项目,不适合用于通用组件,因为这是和模块化背道而驰。

采用 bind 方法

这个是用于回调函数内部调用其他回调函数的场景,比如这样:

1
2
3
4
5
loop() {
// DO SOMETHING

requestAnimationFrame(this.loop.bind(this));
}

配置项、数据的传递问题

多为用到了配置项,可以考虑直接用一个独立模块 option 来管理这些配置项,将用户的个性化配置追加到这个模块上。

但是这样感觉并不是最优解,因为这会导致一个全局的配置对象在各个模块之间传递,破坏了模块的独立性,让程序耦合度很高。

可以考虑通过参数传递的方式来进行一定程度上的解耦,比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Bar{
constructor(DynamicHistogram dynamicHistogram, BarOption option){
super();
this.dynamicHistogram = dynamicHistogram;
this.option = option;
this.init(this.option);
}

init(option) {
// Initialize bars with the option.
}

update() {
this.dynamicHistogram.doSomething();
}
}

这种情况下,TypeScript 的优势就体现出来了,可以指明参数的对象类型,极大增强代码可读性。

时间统计

本来我评估的时间是 10 个小时,这是在基本理清楚原有的代码下评估的,包括所有的功能扩展和代码优化。可实际上我耗费的时间远远不止 10 个小时。

如果某个功能不是 100%做过,那么评估时间的时候就一定要画 XMind 图,把功能点、实现方案细化到极致,否则就会有风险。

“细节决定成败”这句话,在软件开发上体现得淋漓尽致。

分类 耗时(小时) 备注
看懂代码逻辑 1.5
将代码改为类 3
将代码改为模块化 3 有很多需要全局传递的数据,这些数据对于模块化造成了很大的影响
将需要自定义的功能抽取为配置项 5 这个其实还没抽取完,这个属于可以一直持续优化的内容
扩展自定义文本 DIV 2
优化 bar 和 axis 4 不熟悉 D3 的 API,造成了一定的学习成本时间消耗
修正 BUG 3 因为是自己写的代码,知根知底,BUG 修复起来非常快,很多甚至能达到分钟级的修复速度
改为 ESLint 规范 2 因为一些规范不知道设计缘由,需要一边查看 ESLint 文档一边修改,耗时略久

参考资料

炫酷效果

https://youtu.be/ahp7QhbB8G4