用 D3.js 畫一個手機專利關係圖, 看看蘋果,三星,微軟間的專利糾葛 原 薦

用 D3.js 畫一個手機專利關係圖, 看看蘋果,三星,微軟間的專利糾葛

前言

本文靈感來源於Mike Bostock 的一個 demo 頁面

原 demo 基於 D3.js v3 開發, 筆者將其使用 D3.js v5 進行重寫, 並改爲使用 ES6 語法.

源碼: github

在線演示 : demo

效果

demo

可以看到, 上圖左上角爲圖例, 中間爲各個手機公司之間的專利關係圖.

圖例中有三種線段:

  • 紅色實線: 正在進行專利訴訟 (箭頭指向方爲被訴訟方)
  • 藍色虛線: 訴訟已經結束
  • 綠色實線: 專利已經授權

實現

下面讓我們看看如何一步步實現上圖的效果.

分析數據

[
  { 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

想直接聯繫我 ?

郵箱: [email protected]

微信:

wechat

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章