背景

聚类代表了图中元素的分类,节点之间的关系往往影响了聚类之间的关系。例如一个学校的学生可以根据所学专业进行分类,不同专业的学生个体之间的合作关系也映射了不同专业之间的关联性。当图的节点数量庞大,关系复杂时,单纯地绘制出节点和边,视觉上的混乱使得聚类关系变得模糊、难以分辨。一些图布局算法可以根据数据中节点的聚类信息将同类节点聚集在一起,使得聚类在视觉中较为清晰。另外,还可以借助一些例如将节点聚合成 Supernode (超级点)这样的简化可视化的手段,展示聚类的层次。
image.pngimage.png

(左)节点颜色映射了其所属聚类,视觉混乱导致难以分辨聚类间关系。(中)未聚合前的图。(右)聚合简化后的图。

问题

对于如下带聚类信息的数据:节点代表国家,节点的 region 字段代表了该节点所属的大洲或区域,边代表贸易的输入与输出关系。
以下为原数据的部分截取,完整数据请见:filter-trade.json

  1. {
  2. "nodes": [{
  3. "id": "4",
  4. "sim_name": "AFG",
  5. "name": "Afghanistan",
  6. "region": "South Asia",
  7. "resource": "Cereals",
  8. "year": 2012,
  9. "value": 57.847,
  10. "weight": 73.355
  11. },{
  12. "id": "8",
  13. "sim_name": "ALB",
  14. "name": "Albania",
  15. "region": "Europe",
  16. "resource": "Cereals",
  17. "year": 2012,
  18. "value": 556.3332546,
  19. "weight": 1214.363
  20. },
  21. ...
  22. ],
  23. "edges": [{
  24. "eid": "e16",
  25. "source": "4",
  26. "sourceSimName": "AFG",
  27. "sourceRegion": "Afghanistan",
  28. "sourceName": "South Asia",
  29. "target": "56",
  30. "targetSimName": "BEL",
  31. "targetName": "Belgium",
  32. "targetRegion": "Europe",
  33. "resource":"Maize",
  34. "year": "2015",
  35. "value": "9.049",
  36. "weight": "11.52",
  37. "embodiedLand": "4.765",
  38. "embodiedWater":"348.756"
  39. },
  40. ...
  41. ]
  42. }

如果使用 G6 简单地将节点和边渲染出来,不使用任何布局算法和工具,将会得到如下结果:
image.png

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

在上图中,元素错综复杂,基本看不到任何有用的信息。

期待效果

我们希望可以通过合理的视觉映射、合适的布局算法、以及有效的交互方式,简化该图。
simplify-case-all.gif

期待效果。

上图使用到了 G6 中的技术包括:

  • 元素样式的视觉映射;
  • 图的布局;
  • tooltip;
  • 点击交互。

实现步骤

统计必要信息

首先,我们使用简单的 JS 根据数据统计每个节点的聚类(cluster)、进口值(importValue)、出口值(exportValue)。根据节点的聚类,映射节点的颜色 color

  1. const colors = [ '#f5222d', '#faad14', '#a0d911', '#13c2c2', '#1890ff', '#b37feb', '#eb2f96' ];
  2. const nodes = data.nodes;
  3. const edges = data.edges;
  4. const nodeMap = new Map();
  5. const clusterMap = new Map();
  6. let cidx = 0;
  7. // 根据节点的 region 字段,映射节点的颜色。
  8. nodes.forEach(n => {
  9. nodeMap.set(n.id, n);
  10. let region = n.region.split(" ");
  11. if (n.region === 'East Asia') region = n.region;
  12. else region = region[region.length - 1];
  13. if (clusterMap.get(region) === undefined) {
  14. clusterMap.set(region, cidx);
  15. cidx++;
  16. }
  17. const clusterId = clusterMap.get(region);
  18. const color = colors[clusterId % colors.length];
  19. n.style = {
  20. color,
  21. fill: color,
  22. stroke: '#666',
  23. lineWidth: 0.6
  24. };
  25. n.cluster = clusterId;
  26. // 初始化节点的 进口值 和 出口值。
  27. n.importValue = 0;
  28. n.exportValue = 0;
  29. });
  30. // 遍历边,将每条边的 出口值 加到该边的起始点,将 进口值 加到该边的结束点。
  31. edges.forEach(e => {
  32. if (e.value === '') e.value = 0;
  33. const v = parseFloat(e.value);
  34. nodeMap.get(e.source).exportValue += v;
  35. nodeMap.get(e.target).importValue += v;
  36. });
  37. // 映射节点的 出口值 到节点到大小上,节点大小范围是 [2, 12]
  38. mapValueToProp(nodes, 'exportValue', 'size', [2, 12]);

上面代码中 mapValueToProp 函数是将数组中元素的指定字段中到值映射到另一个指定字段的方法,并可以限定映射后的范围。代码如下:

  1. function mapValueToProp(items, valueName, propName, range){
  2. const valueRange = [9999999999, -9999999999];
  3. items.forEach(n => {
  4. if (n[valueName] > valueRange[1]) valueRange[1] = n[valueName];
  5. if (n[valueName] < valueRange[0]) valueRange[0] = n[valueName];
  6. });
  7. const valueLength = valueRange[1] - valueRange[0];
  8. const rLength = range[1] - range[0];
  9. const propNameStrs = propName.split('.');
  10. if (propNameStrs[0] === 'style' && propNameStrs.length > 1) {
  11. items.forEach(n => {
  12. if (n.style === undefined) n.style = {};
  13. n.style[propNameStrs[1]] = rLength * (n[valueName] - valueRange[0]) / valueLength + range[0];
  14. });
  15. } else {
  16. items.forEach(n => {
  17. n[propNameStrs[0]] = rLength * (n[valueName] - valueRange[0]) / valueLength + range[0];
  18. });
  19. }
  20. }

实例化图和配置布局

在这一步中,我们在实例化图时,为之指定布局方法为 fruchterman,并配置该布局使之按照聚类进行布局。另外,我们还设置了节点的形状为 circle,边的形状为 quadratic (二阶贝塞尔曲线)。

  1. const graph = new G6.Graph({
  2. container: 'mountNode',
  3. width: 800,
  4. height: 600,
  5. fitView: true, // 自适应视窗
  6. linkCenter: true, // 边连接到节点的中心
  7. // 配置布局
  8. layout: {
  9. type: 'fruchterman', // 布局方法名
  10. maxIteration: 8000, // 停止迭代的迭代次数
  11. gravity: 10, // 重力大小,数值越大则布局越紧凑
  12. clustering: true, // 是否按照聚类布局
  13. clusterGravity: 30 // 聚类布局重力,数值越大,一个聚类的紧凑程度越高
  14. },
  15. // 配置节点的形状及默认大小
  16. defaultNode: {
  17. shape: 'circle',
  18. size: 5
  19. },
  20. // 配置边的形状
  21. defaultEdge: {
  22. shape: 'quadratic'
  23. }
  24. });

执行绑定和渲染

执行下面代码进行绑定操作和图的数据读入及渲染:

  1. graph.data(data);
  2. graph.render();

更新边的样式

我们如下图(左)希望使用渐变色区分边的方向(出口/进口),绿色一端代表边的起始(出口),红色一端代表边的结束(入口)。
image.pngimage.png

(左)理想效果:用渐变色区分方向。(右)G6 默认根据边在画布上到方向从左到右绘制渐变色。

但 G6 的渐变色默认是根据边在画布上到方向从左到右绘制(如上图右)。为了达到上图(左)的效果,我们需要在有了节点的位置信息之后,再根据边两端节点的位置关系设置渐变方向。由于位置信息是在上一步骤的 graph.render(); 被调用后进行了布局从而生成了位置信息,因此,我们需要在 graph.render(); 之后执行下面代码:

  1. const beginColor = '#5b8c00'; // 边的起始端颜色:绿色
  2. const endColor = '#ff4d4f'; // 边的结束端颜色:红色
  3. // 获取实例中的边对象
  4. const edgeItems = graph.getEdges();
  5. // 遍历边对象
  6. edgeItems.forEach(e => {
  7. const lineWidth = 0.4;
  8. const strokeOpacity = 0.2;
  9. // 默认从左到右绘制绿色到红色
  10. let stroke = 'l(0) 0:' + beginColor + ' 1:' + endColor;
  11. const sourceModel = e.getSource().getModel();
  12. const targetModel = e.getTarget().getModel();
  13. // 若起始端的位置在结束段位置的右边,则反向渐变色:从左到右绘制红色到绿色
  14. if (sourceModel.x > targetModel.x) {
  15. stroke = 'l(0) 0:' + endColor + ' 1:' + beginColor;
  16. }
  17. // 更新该边的样式
  18. e.update({
  19. style: {
  20. lineWidth,
  21. strokeOpacity,
  22. stroke
  23. }
  24. })
  25. });
  26. // 重绘图
  27. graph.paint();


值得注意的是,为了降低绘制成本,G6 不会在每个元素 e.update() 更新样式之后自动重绘,需要手动调用 graph.paint(); 进行重绘。

设置 tooltip

使用 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 的配置项到参数中,如下写法启动了 drag-node 节点拖动操作、 drag-canvas 画图拖动操作,以及 tooltip,在 formatText 函数中指定了 tooltip 显示的文本内容:

  1. modes: {
  2. default: [ 'drag-node', 'drag-canvas', {
  3. type: 'tooltip',
  4. formatText(model) {
  5. let name = '';
  6. let countries = '';
  7. if (model.name) name = model.name + '<br/>';
  8. if (model.countries) countries = '<br/>Number of Countries: ' + model.countries;
  9. const text = name
  10. + 'Export Value: ' + model.exportValue + "(1000USD)"
  11. + '<br/>Region: ' + model.region
  12. + '<br/>Cluster: ' + model.cluster
  13. + countries;
  14. return text;
  15. },
  16. shouldUpdate: e => {
  17. return true;
  18. }
  19. }]
  20. }

这样,当鼠标移动到节点上时,带有国家名字、出口值、所属区域、聚类信息的 tooltip 将会出现:
image.png

tooltip

同时,可以拖拽节点和画布:
simplify-drag.gif

拖拽节点和画布。

点击交互聚合聚类

我们希望通过点击节点可以对聚类进行聚合、展开的操作。如下图所示:
simplify-collapse.gif
上图中的交互逻辑是:

  • 当点击一般节点时:
    • 生成该节点的聚类节点,放置在该聚类所有节点的平均中心,且聚类节点的大小映射该聚类所含有的一般节点数量;
    • 隐藏该聚类的一般节点;
    • 隐藏与该聚类所有一般节点相关的边
    • 根据该聚类一般节点与其他聚类的关系,生成聚类节点与其他聚类的一般节点/聚类节点的边。
  • 当点击聚类节点 A 时:
    • 删除聚类节点 A;
    • 删除与聚类节点 A 相关的边;
    • 显示该聚类的一般节点;
    • 处理该与该聚类一般节点相关的边:
      • 若该边的两端都数据本聚类,则显示;
      • 若该边的其中一端节点 b 属于其他聚类,且 b 是可见的一般节点,则显示;
      • 若该边的其中一端节点 b 属于其他聚类,且 b 不是可见的一般节点,说明节点 b 所在聚类已经被显示为聚类节点 B,则新增一条边连接到聚类节点 B ;

上面的逻辑涉及到了增加元素、删除元素、隐藏元素、显示元素:

  1. graph.addItem('node', nodeModel); // 根据第二个参数 nodeModel,增加一个点元素。nodeModel 是该元素的数据。
  2. graph.addItem('node', edgeModel); // 根据第二个参数 edgeModel,增加一个边元素。edgeModel 是该元素的数据。
  3. graph.removeItem(item); // 删除一个元素。
  4. item.show(); // 显示元素 item。
  5. item.hide(); // 隐藏元素 item。

为实现该效果,我们需要监听节点的点击事件。G6 中的所有监听都挂载在实例 graph 上,这里通过 graph.on('node:click', e => {...}); 监听节点的点击事件。

  1. graph.on('node:click', e => {
  2. const targetItem = e.item;
  3. const model = targetItem.getModel();
  4. const graphEdges = graph.getEdges();
  5. const graphNodes = graph.getNodes();
  6. // 点击了聚类节点
  7. if (model.id.substr(0, 7) === 'cluster') {
  8. graphNodes.forEach(gn => {
  9. const gnModel = gn.getModel();
  10. // 显示该聚类的所有一般节点
  11. if (gnModel.cluster === model.cluster && gnModel.id.substr(0, 7) != 'cluster') {
  12. gn.show();
  13. }
  14. // 移除该聚类节点
  15. if (gnModel.id === model.id) graph.removeItem(gn);
  16. });
  17. graphEdges.forEach(ge => {
  18. const sourceModel = ge.get('sourceNode').getModel();
  19. const targetModel = ge.get('targetNode').getModel();
  20. // 显示该聚类内部的所有边
  21. if ((sourceModel.cluster === model.cluster && sourceModel.id.substr(0, 7) !== 'cluster')
  22. || (targetModel.cluster === model.cluster && targetModel.id.substr(0, 7) !== 'cluster')) {
  23. ge.show();
  24. // 增加连接该聚类一般节点到其他聚类节点的边
  25. if (!ge.get('sourceNode').get('visible') && sourceModel.cluster !== model.cluster) {
  26. let c1 = beginColor, c2 = endColor;
  27. if (model.x > targetModel.x) {
  28. c1 = endColor;
  29. c2 = beginColor;
  30. }
  31. graph.addItem('edge', {
  32. source: 'cluster' + sourceModel.cluster,
  33. target: targetModel.id,
  34. id: 'cluster-edge-' + ge.id,
  35. style: {
  36. stroke: 'l(0) 0:' + c1 + ' 1:' + c2,
  37. lineWidth: 0.4,
  38. strokeOpacity: 0.2
  39. }
  40. });
  41. } else if (ge.get('targetNode').get('visible') && targetModel.cluster !== model.cluster) {
  42. let c1 = beginColor, c2 = endColor;
  43. if (sourceModel.x < model.x) {
  44. c1 = endColor;
  45. c2 = beginColor;
  46. }
  47. graph.addItem('edge', {
  48. source: sourceModel.id,
  49. target: 'cluster' + targetModel.id,
  50. id: 'cluster-edge-' + ge.id,
  51. style: {
  52. stroke: 'l(0) 0:' + c1 + ' 1:' + c2,
  53. lineWidth: 0.4,
  54. strokeOpacity: 0.2
  55. }
  56. });
  57. }
  58. // 若该边的其中一端节点不可见,则隐藏该边
  59. if (!ge.get('sourceNode').get('visible') || !ge.get('targetNode').get('visible')) {
  60. ge.hide();
  61. }
  62. }
  63. // 移除连接该聚类节点的边
  64. if (sourceModel.id === model.id || targetModel.id === model.id) {
  65. graph.removeItem(ge);
  66. }
  67. });
  68. } else { // 点击了一般节点,聚合
  69. // 计算聚类的中心位置
  70. const center = {x: 0, y: 0, count: 0, exportValue: 0};
  71. nodes.forEach(n => {
  72. if (n.cluster === model.cluster) {
  73. center.x += n.x;
  74. center.y += n.y;
  75. center.count++;
  76. center.exportValue += n.exportValue;
  77. }
  78. });
  79. center.x /= center.count;
  80. center.y /= center.count;
  81. // 增加聚类节点,放置在聚类中心
  82. const size = center.count * 1;
  83. const clusterNodeId = 'cluster' + model.cluster;
  84. const color = colors[ model.cluster % colors.length ];
  85. const regionStrs = model.region.split(' ');
  86. let region = regionStrs[regionStrs.length - 1];
  87. if (model.region === 'East Asia') region = model.region;
  88. let labelPosition = 'center';
  89. if (region.length > size) labelPosition = 'left';
  90. const clusterNode = graph.addItem('node', {
  91. x: center.x,
  92. y: center.y,
  93. id: clusterNodeId,
  94. cluster: model.cluster,
  95. region: region,
  96. countries: center.count,
  97. exportValue: center.exportValue,
  98. style: {
  99. color,
  100. fill: color,
  101. stroke: '#666',
  102. lineWidth: 0.6
  103. },
  104. size,
  105. label: region,
  106. labelCfg: {
  107. style: { fontSize: 8.5 },
  108. position: labelPosition
  109. }
  110. });
  111. // 增加连接到该聚类节点的边
  112. graphEdges.forEach(ge => {
  113. const sourceModel = ge.get('sourceNode').getModel();
  114. const targetModel = ge.get('targetNode').getModel();
  115. if (!ge.get('sourceNode').get('visible') || !ge.get('targetNode').get('visible')) return;
  116. if (sourceModel.cluster === model.cluster && targetModel.cluster !== model.cluster) {
  117. let c1 = beginColor, c2 = endColor;
  118. if (center.x > targetModel.x) {
  119. c1 = endColor;
  120. c2 = beginColor;
  121. }
  122. graph.addItem('edge', {
  123. source: clusterNodeId,
  124. target: targetModel.id,
  125. id: 'cluster-edge-' + ge.id,
  126. style: {
  127. stroke: 'l(0) 0:' + c1 + ' 1:' + c2,
  128. lineWidth: 0.4,
  129. strokeOpacity: 0.2
  130. }
  131. });
  132. } else if (targetModel.cluster === model.cluster && sourceModel.cluster !== model.cluster) {
  133. let c1 = beginColor, c2 = endColor;
  134. if (sourceModel.x < center.x) {
  135. c1 = endColor;
  136. c2 = beginColor;
  137. }
  138. graph.addItem('edge', {
  139. source: sourceModel.id,
  140. target: clusterNodeId,
  141. id: 'cluster-edge-' + ge.id,
  142. style: {
  143. stroke: 'l(0) 0:' + c1 + ' 1:' + c2,
  144. lineWidth: 0.4,
  145. strokeOpacity: 0.2
  146. }
  147. });
  148. }
  149. });
  150. // 隐藏该聚类的一般节点
  151. graphNodes.forEach(gn => {
  152. if (gn.getModel().cluster === model.cluster
  153. && gn.getModel().id.substr(0, 7) !== 'cluster') gn.hide();
  154. });
  155. // 隐藏与该聚类一般节点有关的所有边
  156. graphEdges.forEach(ge => {
  157. if (!ge.get('sourceNode').get('visible') || !ge.get('targetNode').get('visible')) ge.hide();
  158. });
  159. }
  160. });

分析

simplify-case-all.gif
最后,让我们一起分析最终结果图给我们带来的信息:

  • East Asia 区域包含两个国家:China(中国),Japan(日本);
  • Other 区域包含三个地区:Antarctica(南极洲),Bunkers(神秘的掩体地区?),Free Zones(神秘的自由地带?);
  • 从一般节点的大小可以看出,出口量最大的国家是欧洲的 Bulgaria(保加利亚),其他出口量较大的有:Lithuania(立陶宛)、Estonia(爱沙尼亚)、Latvia(拉脱维亚)、Paraguay(巴拉圭)、Uruguay(乌拉圭)
  • Anguilla (安圭拉岛,英属岛屿,位于加勒比海)没有任何出口,依靠来自 New Zealand(新西兰)的进口;
  • 从聚合后的聚类节点大小可以看出,参与进出口的国家中,非洲地区的国家数量最多,其次是美洲;
  • 从聚合后的关系可以看出,Other 区域只与非洲和美洲有进出口贸易;
  • 从聚合后的关系可以看出,美洲与各个区域都有密切的贸易;
  • ……

完整代码

自此,该案例完成。完整代码见:Clustering Simplify Case