利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D實例教程
前言
最近公司業(yè)務(wù)服務(wù)老出bug,各路大佬盯著鏈路圖找問題找的頭昏眼花。某天大佬丟了一張圖過來“我們做一個資源拓?fù)鋱D吧,方便大家找bug”。

就是這個圖,應(yīng)該是馬爸爸家的
好吧,來仔細(xì)瞧瞧這個需求咋整呢。一圈資源圍著一個中心的一個應(yīng)用,用曲線連接起來,曲線中段記有應(yīng)用與資源間的調(diào)用信息。emmm 這個看起來很像女神在遛一群舔狗... 啊不,是 d3.js 力導(dǎo)向圖!
d3.js 力導(dǎo)向圖
d3.js 是著名的數(shù)據(jù)可視化基礎(chǔ)工具,他提供了基本的將數(shù)據(jù)映射至網(wǎng)頁元素的能力,同時封裝了大量實用的數(shù)據(jù)操作函數(shù)與圖形算法。其中力導(dǎo)向圖(Force-Directed Graph)是 d3.js 提供的一種十分經(jīng)典的繪圖算法。通過在二維空間里配置節(jié)點和連線,在各種各樣力的作用下,節(jié)點間相互碰撞和運動并在這個過程中不斷地降低能量,最終達(dá)到一種能量很低的安定狀態(tài),形成一種穩(wěn)定的力導(dǎo)向圖。
d3.js 力導(dǎo)向圖中默認(rèn)提供了 5 種作用力(以最新的 5.x 為準(zhǔn)):
中心力(Centering)
中心力作用于所有的節(jié)點而不是某些單獨節(jié)點,可以將所有的節(jié)點的中心一致的向指定的位置移動,而且這種移動不會修改速度也不會影響節(jié)點間的相對位置。
碰撞力(Collision)
碰撞力將每個節(jié)點視為一個具有一定半徑的圓,這個力會阻止代表節(jié)點的這個圓相互重疊,即兩個節(jié)點間會相互碰撞,可以通過設(shè)置 strength 設(shè)置這個碰撞力的強度。
彈簧力(Links)
當(dāng)兩個節(jié)點通過設(shè)置 link 連接到一起后,可以設(shè)置彈簧力,這個力將根據(jù)兩個節(jié)點間的距離將兩個節(jié)點拉近或推遠(yuǎn),力的強度和這個距離成比例就和彈簧一樣。
電荷力(Many-Body)
通過設(shè)置 strength 來模擬所有節(jié)點間的相互作用力,如果為正節(jié)點間就會相互吸引,可以用來模擬電荷吸引力,如果為負(fù)節(jié)點間就會相互排斥。這個力的大小也和節(jié)點間的距離有關(guān)。
定位力(Positioning)
這個力可以將節(jié)點沿著指定的維度推向一個指定位置,比如通過設(shè)置 forceX 和 forceY 就可以在 X軸 和 Y軸 方向推或者拉所有的節(jié)點,forceRadial 則可以形成一個圓環(huán)把所有的節(jié)點都往這個圓環(huán)上相應(yīng)的位置推。
回到這個需求上,其實可以把應(yīng)用、所有的資源與調(diào)用信息都看成節(jié)點,資源之間通過一個較弱的彈簧力與調(diào)用信息連接起來,同時如果應(yīng)用與資源間的調(diào)用有來有往,則在這兩個調(diào)用信息之間加上一個較強的彈簧力。

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);
// 創(chuàng)建一個彈簧力,根據(jù) link 的 strength 值決定強度
const linkForce = d3.forceLink<INode, ILink>(links)
.id(node => node.id)
// 資源節(jié)點與信息節(jié)點間的 strength 小一點,信息節(jié)點間的 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))
// 節(jié)點間相互排斥的電磁力
.force('charge', d3.forceManyBody<INode>().strength(-400))
// 避免節(jié)點相互覆蓋
.force('collision', d3.forceCollide().radius(d => 4))
.force('center', d3.forceCenter(width / 2, height / 2))
.stop();
// 手動調(diào)用 tick 使布局達(dá)到穩(wěn)定狀態(tài)
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 已經(jīng)基本實現(xiàn)啦,那就這樣啦,等后臺同學(xué)實現(xiàn)一下接口就可以上線啦,日均UV兩位數(shù)的產(chǎn)品要啥自行車,有的看就不錯了(手動二哈)。
當(dāng)然不行了,有這么一個都市傳說,中臺產(chǎn)品的好用與否與離職率高低成相關(guān)關(guān)系。本來需要打開資源拓?fù)鋱D就是一件很🤢的事了,再看到這么一款體驗極差的產(chǎn)品,感覺分分鐘就要離職了。為了給我司年交易額兩萬億的長遠(yuǎn)目標(biāo)添磚加瓦,我們來看看有啥需要改進(jìn)的地方。
至少字給我居中吧
注意到我們的字都是左下角定位到節(jié)點中心的,這是因為我們使用的是 svg 的 text 元素,默認(rèn)情況下給 text 元素設(shè)置的 x 和 y 代表了 text 元素 baseLine 的起始位置。當(dāng)然我們可以通過直接設(shè)置 dx 與 dy 設(shè)置一個偏移量來完成居中的問題,但考慮到 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);`;
});
}
效果如下:

字都居中了!
這個線怎么跟激光似的,一點也不像在遛舔狗
再來看看這個線,我們一開始是把所有代表彈簧力的線段當(dāng)成直線就畫上去了,但這樣看起來很生硬效果很差。實際上我們需要的是一條自然的曲線把資源節(jié)點和應(yīng)用節(jié)點連接起來,同時穿過信息節(jié)點,所以問題就變成了如何穿過三個點畫一條曲線。
要畫曲線自然要用到 svg 的 path 元素和他的 d 繪制指令,關(guān)于怎么用 path 畫曲線,這里和MDN上都有很詳細(xì)的教程。在具體實際項目應(yīng)用中,一般來說貝塞爾曲線會比較難把控也比較難獲得較好的效果,所以我們使用 A 指令來畫這個弧線。
使用 A 指令畫弧線,需要知道的元素有:x軸半徑,y軸半徑,弧形旋轉(zhuǎn)角度,角度大小flag,弧線方向flag,弧形的終點。那在已知三個點坐標(biāo)的情況下,怎么求出這些元素呢?是時候復(fù)習(xí)一波三角函數(shù)了。

已知 A、B、C 坐標(biāo)(xaya、xbyb、xcyc),則可求得 a、b、c 長度(Math.sqrt((x1-x2)2 - (y1-y2)2),再根據(jù)余弦定理可求得∠C,再根據(jù)正弦定理可得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
// 過濾出所有的信息節(jié)點
.filter(node => !node.isAppNode)
.forEach((node) => {
...
// 根據(jù)信息節(jié)點的信息得到對應(yīng)的 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,根據(jù)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都沒有,分不清正反啊
應(yīng)用與資源間的關(guān)系,是有方向的,大部分情況下是應(yīng)用調(diào)用資源,也有情況會有雙向的調(diào)用,除了文字意外,我們還需要加上箭頭來表明是誰在調(diào)用誰。怎么加這個箭頭呢?svg 的 path 元素有一個 marker-end 屬性,通過設(shè)置這個屬性可以可以將一個 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')
// 設(shè)置 marker-end 屬性
.attr('marker-end', 'url(#arrow)')
.attr('id', link => link.id)
.attr('stroke-width', 1)
.attr('stroke', '#E5E5E5');
...
但直接這樣寫的話,效果會很差,為啥呢?因為我們 path 元素的起點與終點是節(jié)點的中心點,直接這樣的話箭頭都在節(jié)點上面,如圖:

看到中間那朵菊花沒
所以我們沒法直接通過加這個屬性來加上箭頭,我們需要對 path 做一些處理,對 path 線段去頭去尾。那怎么做呢?還好有巨佬已經(jīng)實現(xiàn)了一種算法,算出兩個 path 元素之間的交點,因此我們可以在算出原 arcPath 后,再算出這條弧線與節(jié)點外一個大一點的圓的交點,再把原 arcPath 的起點與終點移到這兩個點上。
import intersect from 'path-intersection';
const render = () => {
...
nodes
// 過濾出所有的信息節(jié)點
.filter(node => !node.isAppNode)
.forEach((node) => {
...
// 根據(jù)信息節(jié)點的信息得到對應(yīng)的 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,根據(jù)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;
});
...
}

效果已經(jīng)很接近了!
字疊到一起啦,臣妾看不清啊
到這一步整體效果其實已經(jīng)差不多了,但追求完美的我們怎么可能到此為止呢?仔細(xì)看看這個圖,因為調(diào)用信息是一個方盒而不是原型的節(jié)點,如果應(yīng)用和資源間有來有往,那這個字很容易疊到一起??梢試L試調(diào)整碰撞力(Collision)和彈簧力(Links)來讓他們別疊到一起,不過試下來發(fā)現(xiàn)調(diào)整這兩個系數(shù)很容易把整個圖弄得亂七八糟的。那咋辦呢?我們就要到此為止了嗎?不妨換個思路,如果應(yīng)用與資源間有來有往,則這個連接信息就不放到中間點,而是放到開始三分之一處。
說的挺好,我咋知道開始三分之一處在哪?
還好這種「復(fù)雜」的數(shù)學(xué)問題,前人已經(jīng)幫我們探索的差不多了。svg 標(biāo)準(zhǔn)里定義了 SVGGeometryElement.getTotalLength 與 SVGGeometryElement.getPointAtLength 兩個方法,通過這兩個方法我們可以獲得 path 路徑的全長,和某一長度時點的位置。不過這兩個方法都是附在 DOM 元素上的,直接調(diào)用有點麻煩,還好有PureJS 的實現(xiàn):
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);`;
});
...
}
最終效果:

還差一點
效果做到這已經(jīng)差不多了,不過還有一些不完美的地方
- 各種力的系數(shù),在數(shù)據(jù)不同時不能通用,還必須根據(jù)數(shù)據(jù)不同試出來一個相對通用的系數(shù)函數(shù)。
- 不能保證所有的節(jié)點都在方框內(nèi)且不重疊
感覺這兩個問題都算是力導(dǎo)布局的固有缺陷,可能那張圖的實現(xiàn)根本和力導(dǎo)布局沒啥關(guān)系呢😂。不過我們使用力導(dǎo)布局也可以實現(xiàn)不錯的效果,這種 edge case 可以慢慢來解決了就。
總結(jié)
以上就是這篇文章的全部內(nèi)容了,希望本文的內(nèi)容對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,如果有疑問大家可以留言交流,謝謝大家對腳本之家的支持。
相關(guān)文章
javascript 網(wǎng)站常用的iframe分割
就是一個頁面使用兩個iframe來調(diào)用內(nèi)容,實現(xiàn)頁面導(dǎo)航,更容易控制,可控制性好2008-06-06
你不知道的 TypeScript 高級類型(小結(jié))
這篇文章主要介紹了你不知道的 TypeScript 高級類型(小結(jié)),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-08-08
googlemap 之 javascript實現(xiàn)方法
googlemap 之 javascript實現(xiàn)方法...2007-01-01
javascript實現(xiàn)的彈出層背景置灰-模擬(easyui dialog)
本文為大家介紹下使用javascript實現(xiàn)的彈出層背景置灰-模擬(easyui dialog) 具體實現(xiàn)如下,感興趣的朋友可以參考下2013-12-12

