手把手教你使用d3.js畫一個股權穿透圖

語言: CN / TW / HK

theme: channing-cyan

前言

作為一個重度拖延症患者和究極懶狗,鴿了這麼多天都沒有寫過新的文章,真是羞愧。這段時間領導佈置了一個任務,希望我可以做一個股權穿透圖。我本着不重複造輪子的理念,就去github上面去搜了一下,發現還真有一個:

d3.js-tree-2direction.jpg

代碼拷貝下來之後,運行了一下,效果圖是這個樣子的:

d3.js-tree-2direction-master效果圖.jpg

只能説一眼丁真:鑑定為醜,於是我運用我之前學過的svg相關知識,將其改造了一番(順便更換了數據):

股權穿透圖v3.jpg

嗯,這樣子看起來就好看了不少嘛。

不過,我也只是改變了一些樣式,但對其是怎麼實現的還並不理解。

為了搞明白是怎麼實現的,我看了一些關於d3.js相關的文章,對其有了一個初步的瞭解。在我看來,d3.js在使用方式上非常類似於jquery。而它之所以可以用於開發可視化圖表,是因為其內置了非常多與圖形相關的預處理模塊。比如path(在canvas或者svg中繪製路徑)polygon(提供了基礎的二維多邊形幾個基本的操作)。不過以上這兩個模塊我也都還沒有用過,暫且按下不表。

在這個股權穿透圖中,主要用到的模塊有selection(選擇器)hierarchy(層級化)zoom(縮放或者放大)transition(過渡)

令人有些不爽的是,d3.js-tree-2direction使用的d3.js版本是比較老的v3版本,並沒有完善的中文文檔。這就導致我查閲相關的api文檔的時候,要麼翻譯不全,要麼沒有相關的代碼用例。讀起來相當的吃力。而國內的d3.js中文網已經很久沒有更新了,版本停留在了5.x.x

於是後來我注意到d3.js官方推出了一個官方畫廊—— Observable,裏面有很多作者本人寫的示例。在這其中,我找到了最符合我需求的示例——collapsible-tree(可摺疊樹)。不過,這個示例中,d3的版本已經升級為了v6。所幸,功夫不負有心人,在我投入了大量的精力去閲讀這個官方示例代碼和api文檔之後,我終於搞懂了作者本人的代碼編寫思路,然後基於d3的v7版本,自己寫了一套股權穿透圖。接下來,我將詳細闡述一下自己的代碼邏輯,請諸位請我娓娓道來:

代碼邏輯

1、數據處理

(以下示例使用的數據是精簡版的數據)

首先,我們將從後台請求過來的數據整理成如下的嵌套結構:

js let data = { id: "abc1005", // 根節點名稱 name: "山東吠舍科技有限責任公司", // 子節點列表 children: [ { id:"abc1006", name: "山東第一首陀羅科技服務有限公司", percent: '100%', }, { id:"abc1007", name: "山東第二首陀羅程技術有限公司", percent: '100%', }, { id:"abc1008", name: "山東第三首陀羅光伏材料有限公司", percent: '100%', }, { id:"abc1009", name: "山東第四首陀羅科技發展有限公司", percent: '100%', children: [ { id:"abc1010", name: "山東第一達利特瑞利分析儀器有限公司", percent: '100%', children:[ { id:"abc1011", name: "山東瑞利的子公司一", percent: '80%', }, { id:"abc1012", name: "山東瑞利的子公司二", percent: '90%', }, { id:"abc1013", name: "山東瑞利的子公司三", percent: '100%', }, ] } ] }, { id:"abc1014", name: "山東第五首陀羅電工科技有限公司", percent: '100%', children: [ { id:"abc1015", name: "山東第二達利特低自動化設備有限公司", percent: '100%', children:[ { id:"abc1016", name: "山東敬業的子公司一", percent: '100%', }, { id:"abc1017", name: "山東敬業的子公司二", percent: '90%', } ] } ] }, { id: "abc1020", name: "山東第六首陀羅分析儀器(集團)有限責任公司", percent: '100%', children: [ { id:"abc1021", name: "山東第三達利特分氣體工業有限公司", } ] }, ], // 父節點列表 parents: [ { id: "abc2001", name: "山東剎帝利集團有限責任公司", percent: '60%', parents: [ { id: "abc2000", name: "山東婆羅門集團有限公司", percent: '100%', }, ] }, { id: "abc2002", name: "吳小遠", percent: '40%', } ], }

然後,使用d3.hierarchy將嵌套結構數據中的每一個節點處理成相同的格式:

js let root = d3.hierarchy(data);

root打印出來查看其結構:

hierarchy處理過後的數據(2).jpg

如上圖所示,無論是根節點還是子節點,d3.hierarchy都會將其數據本體放入屬性data中,並且還會加上depth以表示層級。並且子節點放入children中,父節點放入parent中。height的話表示節點高度,葉節點(即沒有子節點的節點)height為0。

接下來,定義一個設置好節點之間距離的tree方法,然後將層級化後的數據root傳入進去

js // 200是指節點之間的橫座標的距離,170指的父子節點之間的縱座標之間的距離 let tree = d3.tree().nodeSize([200,170]) tree(root);

再將root打印出來查看一下其結構:

tree處理後的數據(2).jpg

這個時候我們發現tree方法將原來的層級化的數據添加上了兩個屬性xy,即一個節點的座標點。而根節點的座標點為(0,0)

就這樣,我們得知了每個節點所在的座標點,這樣的話我們只需要在對應的座標點上面繪製我們所需的圖形即可。

2、繪製SVG元素

此時,數據已經準備就緒,但是作為數據載體的svg卻連根毛都沒有。這時就需要使用到d3.js操作dom的能力了。

首先,準備一個宿主元素。

```html

```

然後,創建一個svg標籤,置於app中。

```js // d3為目標元素設置屬性的api——attr和jquery的非常相似,幾乎沒有什麼上手門檻。 // svg不設置寬高的話,默認撐滿其父元素。 // 關於viewBox就説來話長了,建議系統學習一下svg相關的標籤和屬性。 const svg = d3.create('svg').attr("viewBox",[-1600/2,-800/2,1600,800]).style("user-select","none");

const app = d3.select("#app");

app.append(()=>{ // node()方法可以得到svg的d3對象對應的DOM對象; return svg.node(); }) ```

接下來,將頁面元素拆解一下。將其分為node(節點)link(連接線)。然後分別創建兩個group(組),基於這兩個組,在其下面創建各種各樣的svg元素。

```js // 將node和link放入同一個父節點中,組成一個整體。便於之後添加拖動和放大縮小的功能。 const gAll = svg.append("g").attr("id","all");

// 之所以gNodes要在gLink之後,是因為在svg中沒有html中的z-index的概念,後面的元素就是會覆蓋掉前面的元素。為了保證節點框能覆蓋掉多餘的連接線,這是必須的措施 // 連接線集合 const gLink = gAll.append("g").attr("id","linkGroup"); // 節點集合 const gNodes = gAll.append("g").attr("id", "nodeGroup"); ```

然後,將數據和元素進行綁定;

```js // descendants方法返回所有的後代節點,自身為第一個節點。 const nodes = root.descendants(); const links = root.links();

// 關於綁定數據的data方法,就是將數據綁定到對應的元素上面。第二個參數可以指定一個標識,使元素上的已綁定數據和nodes中的數據一一對應。 const nodeGroup = gNodes.selectAll("g").data(nodes,(d)=>{ return d.data.id; }) const linksPath = gLink.selectAll("path").data(links,(d)=>{ return d.target.data.id; }) ```

綁定好數據之後,我們就可以使用enter方法來添加各種各樣的svg元素了

先添加矩形框中的各種元素

```js const nodeGroupEnter = nodeGroup.enter().append("g");

// 以下為了精簡代碼,都是使用的固定數字。而且對根節點不同樣式的處理也暫時去掉了

// 矩形外邊框 nodeGroupEnter .append("rect") .attr("width", (d) => { // 設置矩形寬170個單位 return 170; }) .attr("height", (d) => { // 設置矩形高70 return 70; }) .attr("x", (d) => { // 使矩形框向左移動一半距離,向上移動一半距離。使得矩形框可以居中 return -85; }) .attr("y", (d) => { return -35; }) // 設置圓角 .attr("rx", 5) // 描邊寬度1,相當於border-width .attr("stroke-width", 1) // 描邊顏色 .attr("stroke", (d) => { return "#7A9EFF"; }) // 填充色,相當於background-color .attr("fill", (d) => { return "#FFFFFF"; }) // 為其添加的點擊事件 .on("click", (e, d) => { alert(d.data.name); }); // 公司名稱第一行 nodeGroupEnter .append("text") .attr("class", "main-title") .attr("x", 0) .attr("y", (d) => { return -14; }) // 文本錨點,設置為居中 .attr("text-anchor", (d) => { return "middle"; }) .text((d) => { if (d.depth === 0) { return d.data.name; } else { return d.data.name.length > 11 ? d.data.name.substring(0, 11) : d.data.name; } }) .attr("fill", (d) => { return "#000000"; }) .style("font-size", (d) => 14) .style('font-family','黑體') .style("font-weight", "bold");

// 公司名稱第二行 (略) // 控股比例 (略) // 添加“收縮展開按鈕組” const expandBtnGroup = nodeGroupEnter.append("g").attr("class", "expandBtn"); // 添加圓形按鈕 expandBtnGroup.append("circle").attr("r", 8).attr("fill", "#7A9EFF").attr("cy", 8); // 添加加號或者減號 expandBtnGroup.append("text") .attr("text-anchor", "middle") .attr("fill", "#ffffff") .attr("y", 13) .style('font-size', 16) .style('font-family','微軟雅黑') .text((d)=>{ return d.children ? "-" : "+" }); ```

接下來繪製連接線:

js // 繪製直角連接線的方法 drawLink({ source, target }) { const halfDistance = (target.y - source.y) / 2; const halfY = source.y + halfDistance; return `M${source.x},${source.y} L${source.x},${halfY} ${target.x},${halfY} ${target.x},${target.y}`; } // 繪製連接線 linksPath.enter().append("path").attr("d",drawLink).attr("fill", "none").attr("stroke", "#7A9EFF").attr("stroke-width", 1)

當我們做完這些之後,穿透圖的雛形也已經形成了:

雛形 (2).jpg

關於箭頭是如何繪製的,詳情請參照底部的源碼

3、邏輯封裝和添加過渡動畫

接下來。我們先將上面的繪製元素的邏輯封裝成一個方法,使其可以複用。然後增加動效,使各個節點和連接線從漸漸地原點伸展出來。實現如下效果:

動畫演示2.gif

代碼如下:

```js // 將非層級不為0的節點都隱藏起來 root.descendants().forEach((node) => { node._children = node.children || null; if (node.depth) { node.children = null; } });

update();

// 更新節點 function update(source){ (...將上方繪製svg元素的邏輯放入update方法中...) // 生成一個可重複使用的transition實例 const myTransition = this.svg.transition().duration(500);

// 如果source不存在,則創建一個
if (!source) {
    source = {
        x0: 0,
        y0: 0,
    };
    // 設置根節點所在的位置(原點)
    root.x0 = 0;
    root.y0 = 0;
}

// 上面增加節點的功能修改一下,使其剛生成節點的時候,位置處在x0,y0上,並且填充透明度和描邊的透明度都設置成0,先使其不顯示出來
const nodeGroupEnter = nodeGroup.enter().append("g")
    .attr("transform", (d) => {
        return `translate(${source.x0},${source.y0})`;
    })
    .attr("fill-opacity", 0)
    .attr("stroke-opacity", 0)
    .style("cursor", "pointer");

(...省略繪製節點部分的代碼...)

// 繪製連接線也是同理,先使其起點和終點都設置成source的座標點。然後再更改其座標至正確的位置上

linksPath.enter()
    .append("path")
    .attr("d", (d) => {
        let o = {
            source: {
                x: source.x0,
                y: source.y0,
            },
            target: {
                x: source.x0,
                y: source.y0,
            },
        };
        return this.drawLink(o);
    })

(...省略繪製連接線部分的代碼...)

// 有元素更新和元素新增的時候,將節點顯示出來,並且將其位置從原點(源座標)移動至新的位置
nodeGroup
    .merge(node1Enter)
    .transition(myTransition)
    .attr("transform", (d) => {
        return `translate(${d.x},${d.y})`;
    })
    .attr("fill-opacity", 1)
    .attr("stroke-opacity", 1);

// 有元素消失時,將對應的節點隱藏起來,並且將其位置移回原點(源座標)
nodeGroup
    .exit()
    .transition(myTransition)
    .remove()
    .attr("transform", (d) => {
        return `translate(${source.x0},${source.y0})`;
    })
    .attr("fill-opacity", 0)
    .attr("stroke-opacity", 0);
// 連接線部分的邏輯和上面的節點的邏輯相同,也是將其座標移動到新的位置上
// 有元素更新和元素新增時...
linksPath.merge(link1Enter).transition(myTransition).attr("d", this.drawLink);
// 有元素消失時...
linksPath
    .exit()
    .transition(myTransition)
    .remove()
    .attr("d", (d) => {
        let o = {
            source: {
                x: source.x,
                y: source.y,
            },
            target: {
                x: source.x,
                y: source.y,
            },
        };
        return this.drawLink(o);
    });

// node數據改變的時候更改一下加減號
// 這個expandBtn是上面添加的“收縮展開按鈕組”
const expandButtonsSelection = d3.selectAll('g.expandBtn')

expandButtonsSelection.select('text').transition().text((d) =>{
    return d.children ? "-" : "+";
})

// 在最後,將所有節點的當前位置記錄下來,存至x0和y0上
rootOfDown.eachBefore((d) => {
    d.x0 = d.x;
    d.y0 = d.y;
});

} ```

到這兒,我們已經給節點的顯示和隱藏添加上過渡動畫了。接下來,我們還需要給收縮展開按鈕增加點擊事件:

js // 還是在update方法內 // 對上方添加“收縮展開按鈕組”的邏輯修改一下 const expandBtnGroup = nodeGroupEnter.append("g").attr("class", "expandBtn") .attr("transform", (d) => { return `translate(0, 35)`; }) .style("display", (d) => { // 如果沒有子節點,則不顯示 if (!d._children) { return "none"; } }) .on("click", (e, d) => { // 當children中有值,則直接賦值給_children。 if (d.children) { d._children = d.children; d.children = null; } else { // 如果children沒有值,則將_children中的值賦給它 d.children = d._children; } // 然後再調用update方法,將本節點傳入進入,作為之後收縮展開的起點。 this.update(d); });

結語

至此。所有的核心邏輯都已經梳理完畢。但是這裏面用到的d3相關的api礙於篇幅關係就沒辦法細講了,如果想要了解這些API都是什麼意思、具體是如何使用的,可以前去官方文檔瞭解。

在完整版代碼中,為連接線增加上了箭頭,也增加了向上部分查看股東的邏輯。另外,還添加了展開全部收縮全部的功能。

完整版代碼可見https://gitee.com/wushengyuan/equity-penetration-chart.git