用 D3.js 画一个手机专利关系图, 看看苹果,三星,微软间的专利纠葛

打个小广告:
如果你想获取更多前端干货、鹅厂工程师的前端面试指南,
欢迎关注我的个人微信公众号:
前端夜谈

用 D3.js 画一个手机专利关系图, 看看苹果,三星,微软间的专利纠葛

前言

本文灵感来源于Mike Bostock 的一个 demo 页面

原 demo 基于 D3.js v3 开发, 笔者将其使用 D3.js v5 进行重写, 并改为使用 ES6 语法.

源码: github

在线演示 : demo

效果

[图片上传失败...(image-4b13f9-1531707279039)]

可以看到, 上图左上角为图例, 中间为各个手机公司之间的专利关系图.

图例中有三种线段:

  • 红色实线: 正在进行专利诉讼 (箭头指向方为被诉讼方)
  • 蓝色虚线: 诉讼已经结束
  • 绿色实线: 专利已经授权

实现

下面让我们看看如何一步步实现上图的效果.

分析数据

[
  { source: 'Microsoft', target: 'Amazon', type: 'licensing' },
  { source: 'Microsoft', target: 'HTC', type: 'licensing' },
  { source: 'Samsung', target: 'Apple', type: 'suit' },
  { source: 'Motorola', target: 'Apple', type: 'suit' },
  { source: 'Nokia', target: 'Apple', type: 'resolved' },
  { source: 'HTC', target: 'Apple', type: 'suit' },
  { source: 'Kodak', target: 'Apple', type: 'suit' },
  { source: 'Microsoft', target: 'Barnes & Noble', type: 'suit' },
  { source: 'Microsoft', target: 'Foxconn', type: 'suit' },
  ...
]

可以看到, 每一条数据都是由以下几部分组成:

  • source : 诉讼方的公司名称
  • target : 被诉讼方的公司名称
  • type : 当前诉讼状态

需要注意的是: 有一些公司 (如 Apple, Microsoft ) 同时参与了多起诉讼案件, 但我们在数据可视化时只会为每一个公司分配一个节点, 然后用连线表示各个公司之间的关系.

数据可视化最重要的就是数据和图像之间的映射关系, 本例中我们的可视化的逻辑为:

  • 将每一个公司作为图中的一个圆形节点
  • 将每一条诉讼关系表示为两个圆形节点之间的连线

公司 ==> 圆形节点

公司 ==> 圆形节点

诉讼关系 ==> 连线

公司 ==> 圆形节点

技术分析

要实现可以拖动, 自动布局的网络图, 本 demo 用到了 D3.js 中的 d3-forced3-drag , 当然还有最基础的 d3-selection.

(为了方便搭建用户界面, 使用了 Vue 作为前端框架. 但 Vue 并不对数据可视化逻辑产生影响, 不使用也不会对我们的实现造成影响.)

代码实现

现在让我们进入代码部分, 首先我们画出每个公司代表的圆形节点:

上面说到了, 原始数据中, 有部分公司多次出现在不同的诉讼关系中, 而我们要为每个公司画出唯一的节点, 所以我们要对数据进行一些处理:

  initData() {
    this.links = [
      { source: 'Microsoft', target: 'Amazon', type: 'licensing' },
      { source: 'Microsoft', target: 'HTC', type: 'licensing' },
      { source: 'Samsung', target: 'Apple', type: 'suit' },
      { source: 'Motorola', target: 'Apple', type: 'suit' },
      { source: 'Nokia', target: 'Apple', type: 'resolved' },
      ...
    ] // 这里省略了一些数据

    this.nodes = {}

    // Compute the distinct nodes from the links.
    this.links.forEach(link => {
      link.source =
        this.nodes[link.source] ||
        (this.nodes[link.source] = { name: link.source })
      link.target =
        this.nodes[link.target] ||
        (this.nodes[link.target] = { name: link.target })
    })
    console.log(this.links)
  }

上面这段代码的逻辑是, 遍历所有的 links, 将其中的 source 和 target 作为 key 放置到 nodes 中, 这样我们就得到了不含重复节点的数据 nodes:

公司 ==> 圆形节点

细心的读者可能已经发现了, 上面的数据中有许多 x, y 的坐标数据, 这些数据是从哪里来的呢? 答案就是 d3-force, 因为我们要实现的是模拟物理作用力的分布图, 所以我们使用了 d3-force 来模拟并帮助我们计算出每个节点的位置, 调用方法如下:

this.force = this.d3
  .forceSimulation(this.d3.values(this.nodes))
  .force('charge', this.d3.forceManyBody().strength(50))
  .force('collide', this.d3.forceCollide().radius(50))
  .force('link', forceLink)
  .force(
    'center',
    this.d3
      .forceCenter()
      .x(width / 2)
      .y(height / 2)
  )
  .on('tick', () => {
    if (this.path) {
      this.path.attr('d', this.linkArc)
      this.circle.attr('transform', transform)
      this.text.attr('transform', transform)
    }
  })

这里我们为 d3-force 添加了三种作用力:

  • .force('charge', this.d3.forceManyBody().strength(50)) 为每个节点添加互相之间的吸引力
  • .force('collide', this.d3.forceCollide().radius(50)) 为每个节点添加刚体碰撞效果
  • .force('link', forceLink) 添加节点之间的连接力

执行上面的代码后, d3-force 就会为每一个节点计算好坐标并将其 作为 x, y 属性赋予每个节点.

画出代表公司的 圆形节点

处理好了数据, 让我们将其映射到页面上的 svg ==> circle 元素:

this.circle = this.svgNode // svgNode 为页面中的 svg节点 (d3.select('svg'))
  .append('g')
  .selectAll('circle')
  .data(this.d3.values(this.nodes)) // d3.values() 将对象数据 Object{}转换为数组数据 Array[]
  .enter()
  .append('circle')
  .attr('r', 10)
  .style('cursor', 'pointer')
  .call(this.enableDragFunc())

注意到这里我们在最后调用了 .call(this.enableDragFunc()) , 这点代码是为了实现 circle 节点的拖拽功能, 我们在后面再进一步讲解.

上面这段代码逻辑为: 将 nodes 数据映射为 circle 元素, 并设置 circle 元素的属性:

  • 半径 10
  • 鼠标悬停图标为手指
  • 将每个 node 的 x, y 属性赋予 circle 的 x, y (˙ 这一步我们在代码中没有声明, 是因为 d3 默认会将数据的 x, y 属性作为 circle 的 x, y 属性)

执行以上代码后的效果:

circles

画出公司名称

画出代表公司的圆形节点后, 再画出公司名称就很简单了. 只需要将 x, y 坐标进行一定偏移即可.

这里我们将公司名称放在圆形节点的右方:

this.text = this.svgNode
  .append('g')
  .selectAll('text')
  .data(this.d3.values(this.nodes))
  .enter()
  .append('text')
  .attr('x', 12)
  .attr('y', '.31em')
  .text(d => d.name)

上面的代码只是将 text 元素放置在了 (12 , 0 ) 的位置, 我们在 d3-force 的每一个 tick 周期中, 对其 text 进行位置的偏移, 这样就达到了 text 元素在 circle 元素右侧 12 个像素的效果:

this.force = this.d3
      ...
      .on('tick', () => {
        if (this.path) {
          this.path.attr('d', this.linkArc)
          this.circle.attr('transform', transform)
          this.text.attr('transform', transform)
        }
      })

效果如图:

circles

画出诉讼关系连线

接下来我们将有诉讼关系的节点连接起来. 因为连线不是规则的图形, 所以我们使用 svg 的 path 元素来实现.

this.path = this.svgNode
  .append('g')
  .selectAll('path')
  .data(this.links)
  .enter()
  .append('path')
  .attr('class', function(d) {
    return 'link ' + d.type
  })
  .attr('marker-end', function(d) {
    return 'url(#' + d.type + ')'
  })

我们使用 'link ' + d.type 为不同的诉讼关系连线赋予不同的 class, 然后通过 css 对不同 class 的连线添加不同的样式(红色实线, 蓝色虚线, 绿色实线).

pathd 属性我们同样在 d3-force 的 tick 周期中设置:

this.force = this.d3
      ...
      .on('tick', () => {
        if (this.path) {
          this.path.attr('d', this.linkArc)
          this.circle.attr('transform', transform)
          this.text.attr('transform', transform)
        }
      })

  linkArc(d) {
    const dx = d.target.x - d.source.x
    const dy = d.target.y - d.source.y
    const dr = Math.sqrt(dx * dx + dy * dy)
    return (
      'M' +
      d.source.x +
      ',' +
      d.source.y +
      'A' +
      dr +
      ',' +
      dr +
      ' 0 0,1 ' +
      d.target.x +
      ',' +
      d.target.y
    )
  }

这里我们直接用字符串拼接了一小段 svg 的指令, 效果是画出一条圆弧曲线, 完成上面的代码后, 我们得到的效果是:

all svg ready

添加图例

现在我们已经基本完成了预期的效果, 但是图中缺少图例, 访问者会不理解不同颜色的曲线分别代表着什么含义, 所以我们在画面的左上角添加图例.

图例的实现方法大致上面步骤相同, 但是有两个区别:

  • 图例是固定在画面左上角的, 坐标可以在代码中直接写死
  • 图例比真实数据多一个元素: 描述文字

我们构造一下图例的数据:

const sampleData = [
  {
    source: { name: 'Nokia', x: xIndex, y: yIndex },
    target: { name: 'Qualcomm', x: xIndex + 100, y: yIndex },
    title: 'Still in suit:',
    type: 'suit'
  },
  {
    source: { name: 'Qualcomm', x: xIndex, y: yIndex + 100 },
    target: { name: 'Nokia', x: xIndex + 100, y: yIndex + 100 },
    title: 'Already resolved:',
    type: 'resolved'
  },
  {
    source: { name: 'Microsoft', x: xIndex, y: yIndex + 200 },
    target: { name: 'Amazon', x: xIndex + 100, y: yIndex + 200 },
    title: 'Locensing now:',
    type: 'licensing'
  }
]

const nodes = {}
sampleData.forEach((link, index) => {
  nodes[link.source.name + index] = link.source
  nodes[link.target.name + index] = link.target
})

按照同样的步骤, 我们画出图例:

sampleContainer
  .selectAll('path')
  .data(sampleData)
  .enter()
  .append('path')
  .attr('class', d => 'link ' + d.type)
  .attr('marker-end', d => 'url(#' + d.type + ')')
  .attr('d', this.linkArc)

sampleContainer
  .selectAll('circle')
  .data(this.d3.values(nodes))
  .enter()
  .append('circle')
  .attr('r', 10)
  .style('cursor', 'pointer')
  .attr('transform', d => `translate(${d.x}, ${d.y})`)

sampleContainer
  .selectAll('.companyTitle')
  .data(this.d3.values(nodes))
  .enter()
  .append('text')
  .style('text-anchor', 'middle')
  .attr('x', d => d.x)
  .attr('y', d => d.y + 24)
  .text(d => d.name)

sampleContainer
  .selectAll('.title')
  .data(sampleData)
  .enter()
  .append('text')
  .attr('class', 'msg-title')
  .style('text-anchor', 'end')
  .attr('x', d => d.source.x - 30)
  .attr('y', d => d.source.y + 5)
  .text(d => d.title)

最终效果:

all svg ready

总结

使用 D3.js 进行这样的数据可视化非常简单, 而且非常灵活. 只是在使用 d3-force 时需要多调整一下参数来达到理想的效果, 实际实现的代码并不长, 逻辑代码放在这个文件中: graphGenerator.js, 感兴趣的读者不妨直接看看源码.

想继续了解 D3.js

这里是我关于 D3.js数据可视化 博客 的github 地址, 欢迎 start & fork :tada:

D3-blog

如果觉得不错的话, 不妨点击下面的链接关注一下 : )

github: ssthouse

知乎专栏: Data Visualization / 数据可视化

掘金: ssthouse

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容

  • d3 (核心部分)选择集d3.select - 从当前文档中选择一系列元素。d3.selectAll - 从当前文...
    谢大见阅读 3,439评论 1 4
  • 1.发现故事 本课讲述可视化用到的:叙事结构数据收集过程数据处理 2.新闻方法 给可视化添加语境围绕数据进行叙事 ...
    esskeetit阅读 2,796评论 0 2
  • 我不记得这是第几次熬夜写稿子了,写着自己不喜欢的形式文章。每一次也总是有老师帮助我,感谢!
    有一只小熊阅读 219评论 0 0
  • 环境 阿里云 CentOS 7.4 (Linux) 安装方法 本次安装使用rpm安装包的方式参考文章 https:...
    小尘鸟阅读 279评论 0 0
  • 今天学习第三课,蒙版很好玩!
    迷鹿mirror阅读 175评论 4 0