使用 d3.js 力導佈局繪製資源拓撲圖

更多文章,參見大搜車技術博客:blog.souche.com/css

大搜車無線開發中心持續招聘中,前端,Nodejs,android 均有 HC,簡歷直接發到:sunxinyu@souche.comhtml

最近公司業務服務老出bug,各路大佬盯着鏈路圖找問題找的頭昏眼花。某天大佬丟了一張圖過來「咱們作一個資源拓撲圖吧,方便你們找bug」。前端

就是這個圖,應該是馬爸爸家的node

好吧,來仔細瞧瞧這個需求咋整呢。一圈資源圍着一箇中心的一個應用,用曲線鏈接起來,曲線中段記有應用與資源間的調用信息。emmm 這個看起來很像女神在遛一羣舔狗... 啊不,是 d3.js 力導向圖!android

d3.js 力導向圖

d3.js 是著名的數據可視化基礎工具,他提供了基本的將數據映射至網頁元素的能力,同時封裝了大量實用的數據操做函數與圖形算法。其中力導向圖(Force-Directed Graph)是 d3.js 提供的一種十分經典的繪圖算法。經過在二維空間裏配置節點和連線,在各類各樣力的做用下,節點間相互碰撞和運動並在這個過程當中不斷地下降能量,最終達到一種能量很低的安定狀態,造成一種穩定的力導向圖。git

d3.js 力導向圖中默認提供了 5 種做用力(以最新的 5.x 爲準):github

中心力(Centering)

中心力做用於全部的節點而不是某些單獨節點,能夠將全部的節點的中心一致的向指定的位置移動,並且這種移動不會修改速度也不會影響節點間的相對位置。算法

碰撞力(Collision)

碰撞力將每一個節點視爲一個具備必定半徑的圓,這個力會阻止表明節點的這個圓相互重疊,即兩個節點間會相互碰撞,能夠經過設置 strength 設置這個碰撞力的強度。typescript

彈簧力(Links)

當兩個節點經過設置 link 鏈接到一塊兒後,能夠設置彈簧力,這個力將根據兩個節點間的距離將兩個節點拉近或推遠,力的強度和這個距離成比例就和彈簧同樣。npm

電荷力(Many-Body)

經過設置 strength 來模擬全部節點間的相互做用力,若是爲正節點間就會相互吸引,能夠用來模擬電荷吸引力,若是爲負節點間就會相互排斥。這個力的大小也和節點間的距離有關。

定位力(Positioning)

這個力能夠將節點沿着指定的維度推向一個指定位置,好比經過設置 forceXforceY 就能夠在 X軸 和 Y軸 方向推或者拉全部的節點,forceRadial 則能夠造成一個圓環把全部的節點都往這個圓環上相應的位置推。

回到這個需求上,其實能夠把應用、全部的資源與調用信息都當作節點,資源之間經過一個較弱的彈簧力與調用信息鏈接起來,同時若是應用與資源間的調用有來有往,則在這兩個調用信息之間加上一個較強的彈簧力。

ok說幹就幹

// 全部代碼基於 typescript,省略部分代碼

type INode = d3.SimulationNodeDatum & {
  id: string
  label: string;
  isAppNode?: boolean;
};

type ILink = d3.SimulationLinkDatum<INode> & {
  strength: number;
};

const nodes: INode[] = [...];
const links: ILink[] = [...];

const container = d3.select('container');

const svg = container.select('svg')
  .attr('width', width)
  .attr('height', height);

const html = container.append('div')
  .attr('class', styles.HtmlContainer);

// 建立一個彈簧力,根據 link 的 strength 值決定強度
const linkForce = d3.forceLink<INode, ILink>(links) 
  .id(node => node.id)
  // 資源節點與信息節點間的 strength 小一點,信息節點間的 strength 大一點
  .strength(link => link.strength);

const simulation = d3.forceSimulation<INode, ILink>(nodes)
  .force('link', linkForce)
  // 在 y軸 方向上施加一個力把整個圖形壓扁一點
  .force('yt', d3.forceY().strength(() => 0.025)) 
  .force('yb', d3.forceY(height).strength(() => 0.025))
  // 節點間相互排斥的電磁力
  .force('charge', d3.forceManyBody<INode>().strength(-400))
  // 避免節點相互覆蓋
  .force('collision', d3.forceCollide().radius(d => 4))
  .force('center', d3.forceCenter(width / 2, height / 2))
  .stop();

// 手動調用 tick 使佈局達到穩定狀態
for (let i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); i < n; ++i) {
  simulation.tick();
}

const nodeElements = svg.append('g')
  .selectAll('circle')
  .data(nodes)
  .enter().append('circle')
    .attr('r', 10)
    .attr('fill', getNodeColor);

const labelElements = svg.append('g')
  .selectAll('text')
  .data(nodes)
  .enter().append('text')
    .text(node => node.label)
    .attr('font-size', 15);

const pathElements = svg.append('g')
  .selectAll('line')
  .data(links)
  .enter().append('line')
    .attr('stroke-width', 1)
    .attr('stroke', '#E5E5E5');

const render = () => {
  nodeElements
    .attr('cx', node => node.x!)
    .attr('cy', node => node.y!);
  labelElements
    .attr('x', node => node.x!)
    .attr('y', node => node.y!);
  pathElements
    .attr('x1', link => link.source.x)
    .attr('y1', link => link.source.y)
    .attr('x2', link => link.target.x)
    .attr('y2', link => link.target.y);
}

render();
複製代碼

效果以下:

ok 已經基本實現啦,那就這樣啦,等後臺同窗實現一下接口就能夠上線啦,日均UV兩位數的產品要啥自行車,有的看就不錯了(手動二哈)。

固然不行了,有這麼一個都市傳說,中臺產品的好用與否與離職率高低成相關關係。原本須要打開資源拓撲圖就是一件很🤢的事了,再看到這麼一款體驗極差的產品,感受分分鐘就要離職了。爲了給我司年交易額兩萬億的長遠目標添磚加瓦,咱們來看看有啥須要改進的地方。

至少字給我居中吧

注意到咱們的字都是左下角定位到節點中心的,這是由於咱們使用的是 svg 的 text 元素,默認狀況下給 text 元素設置的 x 和 y 表明了 text 元素 baseLine 的起始位置。固然咱們能夠經過直接設置 dxdy 設置一個偏移量來完成居中的問題,但考慮到 svg 元素相比普通的 html 元素畢竟仍是有所限制,並不方便未來的擴展啥的,因此咱們索性把全部的圓點與文字都換成 html 元素。

...

const nodeElements = html.append('div')
  .selectAll('div')
  .data(nodes.filter(node => node.isAppNode))
  .enter().append('div')
    // css modules
    .attr('class', styles.NodeItem)
    .html((node: INode) => {
      return `<p>${node.id}</p>`;
    });

const labelElements = html.append('div')
  .selectAll('div')
  .data(nodes.filter(node => !node.isAppNode))
  .enter().append('div')
    // css modules
    .attr('class', styles.LabelItem)
    .html(node => `
      <p>${node.label}</p>
      <p>Avada Kedavra!</p>
    `);

...

const render = () => {
  nodeElements
    .attr('style', (node) => {
      return `transform: translate3d(calc(${node.x}px - 50%), calc(${node.y}px - 50%), 0);`;
    });

  labelElements
    .attr('style', (node) => {
      return `transform: translate3d(calc(${node.x}px - 50%), calc(${node.y}px - 50%), 0);`;
    });
}
複製代碼

效果以下:

字都居中了!

這個線怎麼跟激光似的,一點也不像在遛舔狗

再來看看這個線,咱們一開始是把全部表明彈簧力的線段當成直線就畫上去了,但這樣看起來很生硬效果不好。實際上咱們須要的是一條天然的曲線把資源節點和應用節點鏈接起來,同時穿過信息節點,因此問題就變成了如何穿過三個點畫一條曲線。

要畫曲線天然要用到 svg 的 path 元素和他的 d 繪製指令,關於怎麼用 path 畫曲線,這裏MDN上都有很詳細的教程。在具體實際項目應用中,通常來講貝塞爾曲線會比較難把控也比較難得到較好的效果,因此咱們使用 A 指令來畫這個弧線。

使用 A 指令畫弧線,須要知道的元素有:x軸半徑,y軸半徑,弧形旋轉角度,角度大小flag,弧線方向flag,弧形的終點。那在已知三個點座標的狀況下,怎麼求出這些元素呢?是時候複習一波三角函數了。

已知 A、B、C 座標(xaya、xbyb、xcyc),則可求得 a、b、c 長度(Math.sqrt((x1-x2)2 - (y1-y2)2),再根據餘弦定理可求得∠C,再根據正弦定理可得r,具體參看代碼:

type IVisualLink = {
  id: string;
  start: number[];
  middle: number[];
  end: number[];
  arcPath: string;
  hasReverseVisualLink: boolean;
};

const visualLinks: IVisualLink[] = [...];

function dist(a: number[], b: number[]) {
  return Math.sqrt(
    Math.pow(a[0] - b[0], 2) +
    Math.pow(a[1] - b[1], 2));
}

...

const pathElements = svg.append('g')
  .selectAll('path')
  .data(visualLinks)
  .enter().append('path')
    .attr('fill', 'none')
    .attr('stroke-width', 1)
    .attr('stroke', '#E5E5E5');

...

const render = () => {
  ...

  nodes
    // 過濾出全部的信息節點
    .filter(node => !node.isAppNode)
    .forEach((node) => {
      ...
      // 根據信息節點的信息獲得對應的 visualLink 對象 index
      const idx = findVisualLinkIndex(node)
      visualLinks[idx].start = [source.x!, source.y!];
      visualLinks[idx].middle = [node.x!, node.y!];
      visualLinks[idx].end = [target.x!, target.y!];

      const A = visualLinks[idx].start;
      const B = visualLinks[idx].end;
      const C = visualLinks[idx].middle;

      const a = dist(B, C);
      const b = dist(C, A);
      const c = dist(A, B);

      // 餘弦定理求得∠C
      const angle = Math.acos((a * a + b * b - c * c) / (2 * a * b));
      // 正弦定理求得外接圓半徑
      const r = _.round(c / Math.sin(angle) / 2, 4);

      // 角度大小flag,由於咱們要的是條弧線而不是一個殘缺的圓,因此恆爲0
      const laf = 0;

      // 弧線方向flag,根據AB的斜率判斷C在AB線的那一邊,再肯定弧線方向
      const saf = +((B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]) < 0);

      const arcPath = ['M', A, 'A', r, r, 0, laf, saf, B].join(' ');

      visualLinks[idx].arcPath = arcPath;
    });

  pathElements
    .attr('d', (link) => {
      return link.arcPath;
    });
}

複製代碼

效果以下:

這些線一對A都沒有,分不清正反啊

應用與資源間的關係,是有方向的,大部分狀況下是應用調用資源,也有狀況會有雙向的調用,除了文字意外,咱們還須要加上箭頭來代表是誰在調用誰。怎麼加這個箭頭呢?svg 的 path 元素有一個 marker-end 屬性,經過設置這個屬性能夠能夠將一個 svg 元素繪製到 path 元素最後的向量上。

// 在 svg 元素中添加一個 marker 元素
<svg>
  <marker
    id="arrow"
    viewBox="-10 -10 20 20"
    markerWidth="20"
    markerHeight="20"
    orient="auto"
  >
    <path
      d="M-6.75,-6.75 L 0,0 L -6.75,6.75"
      fill="none"
      stroke="#E5E5E5"
    />
  </marker>
</svg>

...

const pathElements = svg.append('g')
  .selectAll('path')
  .data(visualLinks)
  .enter().append('path')
    .attr('fill', 'none')
    // 設置 marker-end 屬性
    .attr('marker-end', 'url(#arrow)')
    .attr('id', link => link.id)
    .attr('stroke-width', 1)
    .attr('stroke', '#E5E5E5');

...
複製代碼

但直接這樣寫的話,效果會不好,爲啥呢?由於咱們 path 元素的起點與終點是節點的中心點,直接這樣的話箭頭都在節點上面,如圖:

看到中間那朵菊花沒

因此咱們無法直接經過加這個屬性來加上箭頭,咱們須要對 path 作一些處理,對 path 線段去頭去尾。那怎麼作呢?還好有巨佬已經實現了一種算法,算出兩個 path 元素之間的交點,所以咱們能夠在算出原 arcPath 後,再算出這條弧線與節點外一個大一點的圓的交點,再把原 arcPath 的起點與終點移到這兩個點上。

import intersect from 'path-intersection';

const render = () => {
  ...

  nodes
    // 過濾出全部的信息節點
    .filter(node => !node.isAppNode)
    .forEach((node) => {
      ...
      // 根據信息節點的信息獲得對應的 visualLink 對象 index
      const idx = findVisualLinkIndex(node)
      visualLinks[idx].start = [source.x!, source.y!];
      visualLinks[idx].middle = [node.x!, node.y!];
      visualLinks[idx].end = [target.x!, target.y!];

      const A = visualLinks[idx].start;
      const B = visualLinks[idx].end;
      const C = visualLinks[idx].middle;

      const a = dist(B, C);
      const b = dist(C, A);
      const c = dist(A, B);

      // 餘弦定理求得∠C
      const angle = Math.acos((a * a + b * b - c * c) / (2 * a * b));
      // 正弦定理求得外接圓半徑
      const r = _.round(c / Math.sin(angle) / 2, 4);

      // 角度大小flag,由於咱們要的是條弧線而不是一個殘缺的圓,因此恆爲0
      const laf = 0;

      // 弧線方向flag,根據AB的斜率判斷C在AB線的那一邊,再肯定弧線方向
      const saf = +((B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]) < 0);

      const origArcPath = ['M', A, 'A', r, r, 0, laf, saf, B].join(' ');

      const raidus = NODE_RADIUS;
      const startCirclePath = [
        'M', A,
        'm', [-raidus, 0],
        'a', raidus, raidus, 0, 1, 0, [raidus * 2, 0],
        'a', raidus, raidus, 0, 1, 0, [-raidus * 2, 0],
      ].join(' ');
      const endCirclePath = [
        'M', B,
        'm', [-raidus, 0],
        'a', raidus, raidus, 0, 1, 0, [raidus * 2, 0],
        'a', raidus, raidus, 0, 1, 0, [-raidus * 2, 0],
      ].join(' ');

      const startIntersection = intersect(origArcPath, startCirclePath)[0];
      const endIntersection = intersect(origArcPath, endCirclePath)[0];

      const arcPath = [
        'M', [startIntersection.x, startIntersection.y],
        'A', r, r, 0, laf, saf, [endIntersection.x, endIntersection.y],
      ].join(' ');

      visualLinks[idx].arcPath = arcPath;
    });

  pathElements
    .attr('d', (link) => {
      return link.arcPath;
    });

  ...
}

複製代碼

效果已經很接近了!

字疊到一塊兒啦,臣妾看不清啊

到這一步總體效果其實已經差很少了,但追求完美的咱們怎麼可能到此爲止呢?仔細看看這個圖,由於調用信息是一個方盒而不是原型的節點,若是應用和資源間有來有往,那這個字很容易疊到一塊兒。能夠嘗試調整碰撞力(Collision)和彈簧力(Links)來讓他們別疊到一塊兒,不過試下來發現調整這兩個係數很容易把整個圖弄得亂七八糟的。那咋辦呢?咱們就要到此爲止了嗎?不妨換個思路,若是應用與資源間有來有往,則這個鏈接信息就不放到中間點,而是放到開始三分之一處。

說的挺好,我咋知道開始三分之一處在哪?

還好這種「複雜」的數學問題,前人已經幫咱們探索的差很少了。svg 標準裏定義了 SVGGeometryElement.getTotalLengthSVGGeometryElement.getPointAtLength 兩個方法,經過這兩個方法咱們能夠得到 path 路徑的全長,和某一長度時點的位置。不過這兩個方法都是附在 DOM 元素上的,直接調用有點麻煩,還好有 PureJS 的實現:

import { svgPathProperties } from 'svg-path-properties';

...

render = () => {
  ...

  labelElements
    .attr('style', (link) => {
      const properties = svgPathProperties(link.arcPath);
      const totalLength = properties.getTotalLength();
      const point = properties.getPointAtLength(
        link.hasReverseVisualLink ? totalLength / 3 : totalLength / 2,
      );

      return `transform: translate3d(calc(${point.x}px - 50%), calc(${point.y}px - 50%), 0);`;
    });

  ...
}
複製代碼

最終效果:

還差一點

效果作到這已經差很少了,不過還有一些不完美的地方

  • 各類力的係數,在數據不一樣時不能通用,還必須根據數據不一樣試出來一個相對通用的係數函數。
  • 不能保證全部的節點都在方框內且不重疊

感受這兩個問題都算是力導佈局的固有缺陷,可能那張圖的實現根本和力導佈局沒啥關係呢😂。不過咱們使用力導佈局也能夠實現不錯的效果,這種 edge case 能夠慢慢來解決了就。

Fin

相關文章
相關標籤/搜索