背景

图可视化有时需要借助动画达到展示、分析的目的。合理的动画不仅可以为视觉带来更多炫酷的体验,也能够辅助图的分析。
dynamic animation.gif

图 1. 时序图的动画展示。

问题

有如下数据集,节点北京地铁线路的起点和终点;边代表一条地铁线路,其 controlPoints 字段是边的控制点集合:

  1. {
  2. "nodes": [{
  3. "id": "0",
  4. "x": 12955418.07889617,
  5. "y": 4858516.455509626,
  6. "class": "地铁二号线",
  7. "name": "地铁二号线 0"
  8. }, {
  9. "id": "1",
  10. "x": 12955418.07889617,
  11. "y": 4858516.455509626,
  12. "class": "地铁二号线",
  13. "name": "地铁二号线 1"
  14. },
  15. ...
  16. ],
  17. "edges": [{
  18. "id": "0",
  19. "source": "0",
  20. "target": "1",
  21. "class": "地铁二号线",
  22. "name": "地铁二号线 0",
  23. "controlPoints": [{
  24. "x": 12956158.028659772,
  25. "y": 4858523.099945488
  26. }, {
  27. "x": 12956181.882943347,
  28. "y": 4858523.313643505
  29. }, {
  30. "x": 12956333.569265882,
  31. "y": 4858524.675979247
  32. }, {
  33. "x": 12956722.101097316,
  34. "y": 4858528.426228019
  35. }, {
  36. "x": 12956892.09019568,
  37. "y": 4858529.577087689
  38. },
  39. ...
  40. ]},
  41. ...
  42. ]
  43. }

如果使用 G6 简单地绘制默认样式,将会得到如下结果:
image.png

图 2. G6 渲染原始数据结果。

上图中的边被绘制成了直线,难以看出任何有用信息。

期待效果

我们希望可以利用 G6 绘制一个能够反映一定地理信息,且有交通动感的图。要实现下图效果,涉及到的 G6 主要功能是自定义节点、自定义边,在自定义中实现动画效果。
expected-result.gif

图 3. 期待效果图。

实现步骤

画布背景设置

为了实现荧光效果,并反映地理信息,我们给画布贴了一个暗色系的北京地图。
image.png

在 HTML 或 CSS 中设置 canvasstyle

  1. <style>
  2. canvas{
  3. background: rgb(0, 0, 0);
  4. background-image: url("./assets/data/background-small.png");
  5. background-size: 600px 600px;
  6. }
  7. </style>

自定义呼吸节点

节点的呼吸效果是通过为节点增加循环播放的渐大、渐淡的 Halo 实现的。
breathnode.gif

  1. // 自定义呼吸节点名为 breath-node
  2. G6.registerNode('breath-node', {
  3. afterDraw(cfg, group) {
  4. const r = cfg.size / 2;
  5. const haloColor = cfg.color || (cfg.style && cfg.style.fill);
  6. const back1 = group.addShape('circle', {
  7. zIndex: -2,
  8. attrs: {
  9. x: 0,
  10. y: 0,
  11. r,
  12. fill: haloColor,
  13. opacity: 0.6
  14. }
  15. });
  16. const back2 = group.addShape('circle', {
  17. zIndex: -1,
  18. attrs: {
  19. x: 0,
  20. y: 0,
  21. r,
  22. fill: haloColor,
  23. opacity: 0.6
  24. }
  25. });
  26. group.sort(); // 排序,根据zIndex 排序
  27. const delayBase = Math.random() * 2000;
  28. back1.animate({ // 逐渐放大,并消失
  29. r: r + 10,
  30. opacity: 0.0,
  31. repeat: true // 循环
  32. }, 3000, 'easeCubic', null, delayBase) // 无延迟
  33. back2.animate({ // 逐渐放大,并消失
  34. r: r + 10,
  35. opacity: 0.0,
  36. repeat: true // 循环
  37. }, 3000, 'easeCubic', null, delayBase + 1000) // 1 秒延迟
  38. }
  39. }, 'circle');

自定义边

边的动画效果是通过在边上增加沿着边的路径移动的小圆点实现的。
running-edge-animation.gif

  1. G6.registerEdge('running-polyline', {
  2. afterDraw(cfg, group) {
  3. const shape = group.get('children')[0];
  4. const length = shape.getTotalLength(); // 获取边的总长度
  5. const startPoint = shape.getPoint(0);
  6. let circleCount = Math.ceil(length / 20); // 根据边总长度计算该边上的小圆点数量
  7. circleCount = circleCount === 0 ? 1 : circleCount;
  8. // 生成小圆点
  9. for (let i = 0; i < circleCount; i++) {
  10. // 小圆点动画的随机延迟
  11. const delay = Math.random() * 1000;
  12. const start = shape.getPoint(i / circleCount);
  13. // 加入小圆点
  14. const circle = group.addShape('circle', {
  15. attrs: {
  16. x: start.x,
  17. y: start.y,
  18. r: 0.8,
  19. fill: '#A0F3AF',
  20. shadowColor: '#fff',
  21. shadowBlur: 30,
  22. }
  23. });
  24. // 小圆点的动画
  25. circle.animate({
  26. // 动画的每一帧,返回小圆点在该帧的位置,入参 ratio 是 [0, 1] 的比例值
  27. onFrame(ratio) {
  28. ratio += i / circleCount;
  29. if (ratio > 1) {
  30. ratio %= 1;
  31. }
  32. // 根据比例值获取在边上的位置
  33. const tmpPoint = shape.getPoint(ratio);
  34. return {
  35. x: tmpPoint.x,
  36. y: tmpPoint.y
  37. };
  38. },
  39. repeat: true // 循环动画
  40. }, 10 * length, 'easeCubic', null, delay);
  41. }
  42. }
  43. }, 'polyline'); // 继承 polyline 折线

实例化图

在这一步中,我们在实例化图时,为之指定节点和边的类型(刚才自定义的 breath-noderunning-polyline)、节点样式、边样式。

  1. const graphSize = [ 600, 600 ];
  2. const graph = new G6.Graph({
  3. container: 'mountNode',
  4. width: graphSize[0],
  5. height: graphSize[1],
  6. defaultNode: {
  7. shape: 'breath-node',
  8. size: 3,
  9. style: {
  10. lineWidth: 0,
  11. fill: 'rgb(240, 223, 83)'
  12. }
  13. },
  14. defaultEdge: {
  15. shape: 'running-polyline',
  16. size: 1,
  17. color: 'rgb(14,142,63)',
  18. style: {
  19. opacity: 0.4,
  20. lineAppendWidth: 3
  21. }
  22. }
  23. });

整理数据并渲染

这里使用了JQuery 读取文件中的数据。由于地理数据中的 y 坐标代表的是经度,其方向与画布的 y 轴方向相反,因此需要将其反向。下面代码还使用了 scaleNodesPoints 函数将节点和边归一化到之前定义的图大小 graphSize。另外,我们根据边的聚类信息设置边的颜色。

  1. // 边的颜色数组
  2. const colors = [ 'rgb(64, 174, 247)', 'rgb(108, 207, 169)', 'rgb(157, 223, 125)',
  3. 'rgb(240, 198, 74)', 'rgb(221, 158, 97)', 'rgb(141, 163, 112)',
  4. 'rgb(115, 136, 220)', 'rgb(133, 88, 219)', 'rgb(203, 135, 226)',
  5. 'rgb(227, 137, 163)' ];
  6. // 使用 JQuery 读取数据文件
  7. $.getJSON('./assets/data/beijing-metro-lines.json', data => {
  8. const nodes = data.nodes;
  9. const edges = data.edges;
  10. const classMap = new Map();
  11. let classId = 0;
  12. // 反向节点的 y 坐标
  13. nodes.forEach(node => {
  14. node.y = -node.y;
  15. });
  16. edges.forEach(edge => {
  17. // 根据边的聚类信息设置边的颜色。同一条地铁线路使用同一种颜色
  18. if (edge.class && classMap.get(edge.class) === undefined) {
  19. classMap.set(edge.class, classId);
  20. classId ++;
  21. }
  22. const cid = classMap.get(edge.class);
  23. edge.color = colors[cid % colors.length];
  24. const controlPoints = edge.controlPoints;
  25. // 反向边控制点的 y 坐标
  26. controlPoints.forEach(cp => {
  27. cp.y = -cp.y;
  28. });
  29. })
  30. scaleNodesPoints(nodes, edges, graphSize);
  31. graph.data(data); // 为图实例配置数据源
  32. graph.render(); // 渲染图
  33. });

scaleNodesPoints 函数如下:

  1. function scaleNodesPoints(nodes, edges, graphSize) {
  2. const size = graphSize[0] < graphSize[1] ? graphSize[0] : graphSize[1];
  3. let minX = 99999999999999999;
  4. let maxX = -99999999999999999;
  5. let minY = 99999999999999999;
  6. let maxY = -99999999999999999;
  7. nodes.forEach(node => {
  8. if (node.x > maxX) maxX = node.x;
  9. if (node.x < minX) minX = node.x;
  10. if (node.y > maxY) maxY = node.y;
  11. if (node.y < minY) minY = node.y;
  12. });
  13. edges.forEach(edge => {
  14. const controlPoints = edge.controlPoints;
  15. controlPoints.forEach(cp => {
  16. if (cp.x > maxX) maxX = cp.x;
  17. if (cp.x < minX) minX = cp.x;
  18. if (cp.y > maxY) maxY = cp.y;
  19. if (cp.y < minY) minY = cp.y;
  20. });
  21. });
  22. const xScale = maxX - minX;
  23. const yScale = maxY - minY;
  24. nodes.forEach(node => {
  25. node.orix = node.x;
  26. node.oriy = node.y;
  27. node.x = (node.x - minX) / xScale * size;
  28. node.y = (node.y - minY) / yScale * size;
  29. });
  30. edges.forEach(edge => {
  31. const controlPoints = edge.controlPoints;
  32. controlPoints.forEach(cp => {
  33. cp.x = (cp.x - minX) / xScale * size;
  34. cp.y = (cp.y - minY) / yScale * size;
  35. });
  36. });
  37. }

设置 edge-tooltip

使用 edge-tooltip,可以在鼠标 hover 到边上时展示该边的某些信息。首先在 HTML 中设定 tooltip 的样式:

  1. <style>
  2. .g6-tooltip {
  3. border: 1px solid #e2e2e2;
  4. border-radius: 4px;
  5. font-size: 12px;
  6. color: #545454;
  7. background-color: rgba(255, 255, 255, 0.9);
  8. padding: 10px 8px;
  9. box-shadow: rgb(174, 174, 174) 0px 0px 10px;
  10. }
  11. </style>

然后,在上一步实例化 graph 时,增加一个名为 modes 的配置项到参数中,如下写法启动了 edge-tooltip,在 formatText 函数中指定了 edge-tooltip 显示的文本内容:

  1. modes: {
  2. default: [{
  3. type: 'edge-tooltip',
  4. formatText(model) {
  5. const text = model.class;
  6. return text;
  7. },
  8. shouldUpdate: e => {
  9. return true;
  10. }
  11. }]
  12. }

这样,当鼠标移动到节点上时,带有该边所属地铁线路信息的 tooltip 将会出现:
edge-tooltip-metro.gif

edge-tooltip

最终效果

expected-result.gif

最终效果图。节点代表地铁线路的起点和终点。边的颜色代表不同的地铁线路。鼠标放置在边上出现有该边所属的地铁线路名的 edge-tooltip。

完整代码

自此,该案例完成。完整代码参见:Metro Animation Case