D3 画图小记

对这是我新建的分类,因为昨天吃了一份 pasta,好稀饭里面的肉酱

因为需要实现一个多个服务之间的依赖关系,所以接触到了 d3,用 d3.js 去画图可以实现许多自定义的效果、和交互响应。(ps: 这篇就别用手机看了吧,可能会卡)
之前在群里听到 @joyeecheung 用 d3 在做 alinode 官网的交互,因为很多交互可以自定义,可以开出一些脑洞

记得大二刚知道 js 没多久就 star 过,但一直就没有用,因为感觉 d3 api 熟悉起来有点门槛,而且似乎没有做那种自定义交互的诉求

关于 d3 的 api 中文文档可以参考这里,里面一些细节的说明还是英文的

其实用一些简单的功能的话,d3常用的 api 无外乎如下

选择元素 - d3.select
设置布局 - d3.layout

布局这个就有很多种类了,直方图布局(histogram),簇布局(cluster),力布局(cluster),弦布局(chord) …不一一解释

为选中元素添加元素 - selection.append
修改选中元素属性/样式 - selection.attr / selection.style
添加或删除选中元素的类 - selection.classed

d3.js

/img/2016/d3-graph/d3js-homepage.jpg

d3是一个用 JavaScript 来构建各种各样丰富图案的库,可以用 HTML, CSS, SVG 来构建各种自定义交互的图案,
很多人估计第一眼看见这个图就已经画了,肯定觉得哇塞好屌是不是可以随便套一个结果一打开哇塞好复杂还要操作一个个 dom 节点?
然后关闭了网站…

其实不是这样的,一切要从需求触发。我们要做的是依赖关系图,需要可视化的 数据结构(data structure) 是一个
Object Array,他的数据应该大致如下才对

[
  {
    "name": "servier_01",
    "dependencies": [
      "service_02",
      "base_03",
      "app_04",
      "lib05"
    ]
  },
  {
    "name": "servier_02",
    "dependencies": [
      "lib_02",
      "base_01",
      ...
    ]
  },
]

中间肯定有各种各样的 case,你会疑问他为什么不是树状图,@yk老师说因为一般情况下并不能非常理想地将服务剥离出来,
从基础服务到高级服务,层次清晰并且无环,有时候还是会有环出现,所以树状图、扩散树状图(Tilford Tree)、
流程图(只是说那种方块,直线的图,能想来吧)我们基本上可以放弃了

/img/2016/d3-graph/chord-graph.jpg

/img/2016/d3-graph/tilford-tree-graph.jpg

从上面两种类型图的关系来看,不太符合实际需求,虽然我挺喜欢这样清晰的结构的

@zw 老师问我们能否做出数据流向的效果,我猜可以应该是可以的吧,但就是这样代价实在是太大了,如果是单个 SPA 做这个东西,
我觉得倒还比较合适,遂放弃这个高级进阶,那么目标清晰,做一个 “可能有环的有向图”

Implementation,动手

最早我看到了一个叫 d3-process-map 的页面,效果如下



其实这个已经基本上已经完全满足需求了,不过还是要看一下它到底做了些什么事情

我 clone 下来 d3-process-map 的源码看了一下

基本结构有三部分,包含 数据 和 d3的交互,以及一个名叫 geometry (以下简称 geo) 的计算节点之间距离的模块。
(我开始先去掉了 geo,想看看效果,后来还是出了问题)

整个 SVG 图由 node节点(<rect/>),文字(<text/>),和连线(<line/>),线有剪头(<marker/>) 这些元素组成

我们之前有一个残次的图,比较不清晰,没法看懂节点之间的关系,原因就是拖动节点(单个服务)的时候晃动很大,
而且有的服务依赖和被依赖的次数太多,如果某个基础服务被依赖50次,那么50条线混在一起很难看清楚的,在有环的情况下

我觉得他的图有几个好处

  • 线比较长,节点成簇,簇与簇之间的斥力比较大
  • 线有箭头,粗细合适,可以体现出依赖和被依赖关系
  • 节点宽大,bgColor友好舒适
  • label 包含在节点内,看得清楚
  • 有交互,对所选的部分有高亮

需要针对我们的需求进行改进

  1. 箭头,这个必须要有

<svg>中使用<marker>,这样所有的line就会有剪头,顺便调整一下线的粗细都可以的

  1. label 做成方块,字体需要居中才能看清

爆栈上说对 text 标签添加下面的 text-anchor: middle 的属性即可

text.attr('text-anchor', 'middle')

其实不单单要这样,geo 那个模块完成了一些复杂的工作,对<text>进行getBBox 操作,拿到 text 真实的宽高,经过计算让 label 完整地被
<rect/>包在里面,所以 geo 必不可少

  1. 交互

依赖当然有两种,主动依赖被动依赖,那么箭头在混乱的途中当然无法完全满足需求,所以高亮是一个非常必要的工作。
很简单,hover 的时候,把依赖和没依赖的不同高亮就好了,我选择用单击切换两种情况 (主动式和被动式)

如何找到关系?每个node其实有 source 和 target 两种关系,用 classes(someClass, callback),添加或者删除CSS class,
来达到样式的变化 (透明度变化或者颜色,都可以),callback 里判断 source 或 target 和当前节点的关系进行批量选中

基本上这个修改就算 ok,然后进行一些 es5 到 es6 的翻译,写成 class 用 webpack 打包,用的时候会比较友好一些

小插曲

这个 Dependency Graph 上线一段时间后挂掉了,有点疑惑开始不都是好的么,后来发现是 babel 编译 es6 的问题。之前大概这么调用的


// define in depsGraph.js

export default class DepsGraph { /* ... implementation ... */ }

// webpack include

window.DepsGraph = require('./depsGraph')

// call

DepsGraph.draw(/* data */)

下面我们来看个小栗子,我用写一个叫 CLA 的类

export default class ClA {
  a = 3
}

babel 会编译成

"use strict";

Object.defineProperty(exports, "__esModule", {
    value: true
});

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var ClA = function ClA() {
    _classCallCheck(this, ClA);

    this.a = 3;
};

exports.default = ClA;

看到没,最后翻译的结果是exports.default = CLA

require 的时候,会被 require 为一个 module,而不像 import 会去被翻译成处理 export default的情况

那么最后得到的 module 自然就是

{
  __esModule: {
    value: true
  },
  default: {
    // Class CLA...
    draw: function() {...}
  }
}

import 则可以得到正确的 module

{
  // Class CLA...
  draw: function() {...}
}