溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

發(fā)布時(shí)間:2021-05-19 11:43:18 來(lái)源:億速云 閱讀:386 作者:小新 欄目:web開(kāi)發(fā)

這篇文章給大家分享的是有關(guān)如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D的內(nèi)容。小編覺(jué)得挺實(shí)用的,因此分享給大家做個(gè)參考,一起跟隨小編過(guò)來(lái)看看吧。

前言

最近公司業(yè)務(wù)服務(wù)老出bug,各路大佬盯著鏈路圖找問(wèn)題找的頭昏眼花。某天大佬丟了一張圖過(guò)來(lái)“我們做一個(gè)資源拓?fù)鋱D吧,方便大家找bug”。

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

就是這個(gè)圖,應(yīng)該是馬爸爸家的

好吧,來(lái)仔細(xì)瞧瞧這個(gè)需求咋整呢。一圈資源圍著一個(gè)中心的一個(gè)應(yīng)用,用曲線連接起來(lái),曲線中段記有應(yīng)用與資源間的調(diào)用信息。emmm 這個(gè)看起來(lái)很像女神在遛一群舔狗... 啊不,是 d3.js 力導(dǎo)向圖!

d3.js 力導(dǎo)向圖

d3.js 是著名的數(shù)據(jù)可視化基礎(chǔ)工具,他提供了基本的將數(shù)據(jù)映射至網(wǎng)頁(yè)元素的能力,同時(shí)封裝了大量實(shí)用的數(shù)據(jù)操作函數(shù)與圖形算法。其中力導(dǎo)向圖(Force-Directed Graph)是 d3.js 提供的一種十分經(jīng)典的繪圖算法。通過(guò)在二維空間里配置節(jié)點(diǎn)和連線,在各種各樣力的作用下,節(jié)點(diǎn)間相互碰撞和運(yùn)動(dòng)并在這個(gè)過(guò)程中不斷地降低能量,最終達(dá)到一種能量很低的安定狀態(tài),形成一種穩(wěn)定的力導(dǎo)向圖。

d3.js 力導(dǎo)向圖中默認(rèn)提供了 5 種作用力(以最新的 5.x 為準(zhǔn)):

中心力(Centering)

中心力作用于所有的節(jié)點(diǎn)而不是某些單獨(dú)節(jié)點(diǎn),可以將所有的節(jié)點(diǎn)的中心一致的向指定的位置移動(dòng),而且這種移動(dòng)不會(huì)修改速度也不會(huì)影響節(jié)點(diǎn)間的相對(duì)位置。

碰撞力(Collision)

碰撞力將每個(gè)節(jié)點(diǎn)視為一個(gè)具有一定半徑的圓,這個(gè)力會(huì)阻止代表節(jié)點(diǎn)的這個(gè)圓相互重疊,即兩個(gè)節(jié)點(diǎn)間會(huì)相互碰撞,可以通過(guò)設(shè)置 strength 設(shè)置這個(gè)碰撞力的強(qiáng)度。

彈簧力(Links)

當(dāng)兩個(gè)節(jié)點(diǎn)通過(guò)設(shè)置 link 連接到一起后,可以設(shè)置彈簧力,這個(gè)力將根據(jù)兩個(gè)節(jié)點(diǎn)間的距離將兩個(gè)節(jié)點(diǎn)拉近或推遠(yuǎn),力的強(qiáng)度和這個(gè)距離成比例就和彈簧一樣。

電荷力(Many-Body)

通過(guò)設(shè)置 strength 來(lái)模擬所有節(jié)點(diǎn)間的相互作用力,如果為正節(jié)點(diǎn)間就會(huì)相互吸引,可以用來(lái)模擬電荷吸引力,如果為負(fù)節(jié)點(diǎn)間就會(huì)相互排斥。這個(gè)力的大小也和節(jié)點(diǎn)間的距離有關(guān)。

定位力(Positioning)

這個(gè)力可以將節(jié)點(diǎn)沿著指定的維度推向一個(gè)指定位置,比如通過(guò)設(shè)置 forceX 和 forceY 就可以在 X軸 和 Y軸 方向推或者拉所有的節(jié)點(diǎn),forceRadial 則可以形成一個(gè)圓環(huán)把所有的節(jié)點(diǎn)都往這個(gè)圓環(huán)上相應(yīng)的位置推。

回到這個(gè)需求上,其實(shí)可以把應(yīng)用、所有的資源與調(diào)用信息都看成節(jié)點(diǎn),資源之間通過(guò)一個(gè)較弱的彈簧力與調(diào)用信息連接起來(lái),同時(shí)如果應(yīng)用與資源間的調(diào)用有來(lái)有往,則在這兩個(gè)調(diào)用信息之間加上一個(gè)較強(qiáng)的彈簧力。

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

ok說(shuō)干就干

// 所有代碼基于 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)建一個(gè)彈簧力,根據(jù) link 的 strength 值決定強(qiáng)度
const linkForce = d3.forceLink<INode, ILink>(links) 
 .id(node => node.id)
 // 資源節(jié)點(diǎn)與信息節(jié)點(diǎn)間的 strength 小一點(diǎn),信息節(jié)點(diǎn)間的 strength 大一點(diǎn)
 .strength(link => link.strength);

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

// 手動(dòng)調(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();

效果如下:

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

ok 已經(jīng)基本實(shí)現(xiàn)啦,那就這樣啦,等后臺(tái)同學(xué)實(shí)現(xiàn)一下接口就可以上線啦,日均UV兩位數(shù)的產(chǎn)品要啥自行車(chē),有的看就不錯(cuò)了(手動(dòng)二哈)。

當(dāng)然不行了,有這么一個(gè)都市傳說(shuō),中臺(tái)產(chǎn)品的好用與否與離職率高低成相關(guān)關(guān)系。本來(lái)需要打開(kāi)資源拓?fù)鋱D就是一件很?的事了,再看到這么一款體驗(yàn)極差的產(chǎn)品,感覺(jué)分分鐘就要離職了。為了給我司年交易額兩萬(wàn)億的長(zhǎng)遠(yuǎn)目標(biāo)添磚加瓦,我們來(lái)看看有啥需要改進(jìn)的地方。

至少字給我居中吧

注意到我們的字都是左下角定位到節(jié)點(diǎn)中心的,這是因?yàn)槲覀兪褂玫氖?svg 的 text 元素,默認(rèn)情況下給 text 元素設(shè)置的 x 和 y 代表了 text 元素 baseLine 的起始位置。當(dāng)然我們可以通過(guò)直接設(shè)置 dx 與 dy 設(shè)置一個(gè)偏移量來(lái)完成居中的問(wèn)題,但考慮到 svg 元素相比普通的 html 元素畢竟還是有所限制,并不方便將來(lái)的擴(kuò)展啥的,所以我們索性把所有的圓點(diǎn)與文字都換成 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);`;
 });
}

效果如下:

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

字都居中了!

這個(gè)線怎么跟激光似的,一點(diǎn)也不像在遛舔狗

再來(lái)看看這個(gè)線,我們一開(kāi)始是把所有代表彈簧力的線段當(dāng)成直線就畫(huà)上去了,但這樣看起來(lái)很生硬效果很差。實(shí)際上我們需要的是一條自然的曲線把資源節(jié)點(diǎn)和應(yīng)用節(jié)點(diǎn)連接起來(lái),同時(shí)穿過(guò)信息節(jié)點(diǎn),所以問(wèn)題就變成了如何穿過(guò)三個(gè)點(diǎn)畫(huà)一條曲線。

要畫(huà)曲線自然要用到 svg 的 path 元素和他的 d 繪制指令,關(guān)于怎么用 path 畫(huà)曲線,這里和MDN上都有很詳細(xì)的教程。在具體實(shí)際項(xiàng)目應(yīng)用中,一般來(lái)說(shuō)貝塞爾曲線會(huì)比較難把控也比較難獲得較好的效果,所以我們使用 A 指令來(lái)畫(huà)這個(gè)弧線。

使用 A 指令畫(huà)弧線,需要知道的元素有:x軸半徑,y軸半徑,弧形旋轉(zhuǎn)角度,角度大小flag,弧線方向flag,弧形的終點(diǎn)。那在已知三個(gè)點(diǎn)坐標(biāo)的情況下,怎么求出這些元素呢?是時(shí)候復(fù)習(xí)一波三角函數(shù)了。

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

已知 A、B、C 坐標(biāo)(xaya、xbyb、xcyc),則可求得 a、b、c 長(zhǎng)度(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
 // 過(guò)濾出所有的信息節(jié)點(diǎn)
 .filter(node => !node.isAppNode)
 .forEach((node) => {
 ...
 // 根據(jù)信息節(jié)點(diǎn)的信息得到對(duì)應(yīng)的 visualLink 對(duì)象 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,因?yàn)槲覀円氖菞l弧線而不是一個(gè)殘缺的圓,所以恒為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;
 });
}

效果如下:

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

這些線一對(duì)A都沒(méi)有,分不清正反啊

應(yīng)用與資源間的關(guān)系,是有方向的,大部分情況下是應(yīng)用調(diào)用資源,也有情況會(huì)有雙向的調(diào)用,除了文字意外,我們還需要加上箭頭來(lái)表明是誰(shuí)在調(diào)用誰(shuí)。怎么加這個(gè)箭頭呢?svg 的 path 元素有一個(gè) marker-end 屬性,通過(guò)設(shè)置這個(gè)屬性可以可以將一個(gè) svg 元素繪制到 path 元素最后的向量上。

// 在 svg 元素中添加一個(gè) 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');

...

但直接這樣寫(xiě)的話,效果會(huì)很差,為啥呢?因?yàn)槲覀?path 元素的起點(diǎn)與終點(diǎn)是節(jié)點(diǎn)的中心點(diǎn),直接這樣的話箭頭都在節(jié)點(diǎn)上面,如圖:

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

看到中間那朵菊花沒(méi)

所以我們沒(méi)法直接通過(guò)加這個(gè)屬性來(lái)加上箭頭,我們需要對(duì) path 做一些處理,對(duì) path 線段去頭去尾。那怎么做呢?還好有巨佬已經(jīng)實(shí)現(xiàn)了一種算法,算出兩個(gè) path 元素之間的交點(diǎn),因此我們可以在算出原 arcPath 后,再算出這條弧線與節(jié)點(diǎn)外一個(gè)大一點(diǎn)的圓的交點(diǎn),再把原 arcPath 的起點(diǎn)與終點(diǎn)移到這兩個(gè)點(diǎn)上。

import intersect from 'path-intersection';

const render = () => { 
 ...

 nodes
 // 過(guò)濾出所有的信息節(jié)點(diǎn)
 .filter(node => !node.isAppNode)
 .forEach((node) => {
 ...
 // 根據(jù)信息節(jié)點(diǎn)的信息得到對(duì)應(yīng)的 visualLink 對(duì)象 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,因?yàn)槲覀円氖菞l弧線而不是一個(gè)殘缺的圓,所以恒為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;
 });

 ...
}

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

效果已經(jīng)很接近了!

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

到這一步整體效果其實(shí)已經(jīng)差不多了,但追求完美的我們?cè)趺纯赡艿酱藶橹鼓??仔?xì)看看這個(gè)圖,因?yàn)檎{(diào)用信息是一個(gè)方盒而不是原型的節(jié)點(diǎn),如果應(yīng)用和資源間有來(lái)有往,那這個(gè)字很容易疊到一起??梢試L試調(diào)整碰撞力(Collision)和彈簧力(Links)來(lái)讓他們別疊到一起,不過(guò)試下來(lái)發(fā)現(xiàn)調(diào)整這兩個(gè)系數(shù)很容易把整個(gè)圖弄得亂七八糟的。那咋辦呢?我們就要到此為止了嗎?不妨換個(gè)思路,如果應(yīng)用與資源間有來(lái)有往,則這個(gè)連接信息就不放到中間點(diǎn),而是放到開(kāi)始三分之一處。

說(shuō)的挺好,我咋知道開(kāi)始三分之一處在哪?

還好這種「復(fù)雜」的數(shù)學(xué)問(wèn)題,前人已經(jīng)幫我們探索的差不多了。svg 標(biāo)準(zhǔn)里定義了 SVGGeometryElement.getTotalLength 與 SVGGeometryElement.getPointAtLength 兩個(gè)方法,通過(guò)這兩個(gè)方法我們可以獲得 path 路徑的全長(zhǎng),和某一長(zhǎng)度時(shí)點(diǎn)的位置。不過(guò)這兩個(gè)方法都是附在 DOM 元素上的,直接調(diào)用有點(diǎn)麻煩,還好有PureJS 的實(shí)現(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);`;
 });

 ...
}

最終效果:

如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D

還差一點(diǎn)

效果做到這已經(jīng)差不多了,不過(guò)還有一些不完美的地方

  • 各種力的系數(shù),在數(shù)據(jù)不同時(shí)不能通用,還必須根據(jù)數(shù)據(jù)不同試出來(lái)一個(gè)相對(duì)通用的系數(shù)函數(shù)。

  • 不能保證所有的節(jié)點(diǎn)都在方框內(nèi)且不重疊

感覺(jué)這兩個(gè)問(wèn)題都算是力導(dǎo)布局的固有缺陷,可能那張圖的實(shí)現(xiàn)根本和力導(dǎo)布局沒(méi)啥關(guān)系呢?。不過(guò)我們使用力導(dǎo)布局也可以實(shí)現(xiàn)不錯(cuò)的效果,這種 edge case 可以慢慢來(lái)解決了就。

感謝各位的閱讀!關(guān)于“如何利用d3.js力導(dǎo)布局繪制資源拓?fù)鋱D”這篇文章就分享到這里了,希望以上內(nèi)容可以對(duì)大家有一定的幫助,讓大家可以學(xué)到更多知識(shí),如果覺(jué)得文章不錯(cuò),可以把它分享出去讓更多的人看到吧!

向AI問(wèn)一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI