在 Emberjs 項目中由淺入深使用 D3.js 繪製圖表

ember-d3-demo

ember-cli v3.16.0
node 10.16.0

項目地址

0. 前言

  1. 它是聲明式的,不是命令式的d3的第一個核心是:數據驅動的dom元素創建,把這個思想上的彎繞過來,掌握1/3了
  2. 它是數據處理包,不是圖形繪製包d3的第二個核心是:它的大量的api,提供的是對數據的轉換與處理,無論是scale、layout還是svg.line等,都僅僅是對數據的處理,和繪製圖形與DOM操作沒有半毛關係。把這個思想上的彎繞過來,又掌握1/3了
  3. 它的api通常返回的是一個函數,這個函數的具體功能,通過函數對象的方法約定。d3的javascript寫法不是那麼符合常人的邏輯,比如:調用d3.svg.line(),這個我們獲得的是一個line函數,作用是把原始數據轉化成svg的path元素的d屬性需要的字符串,如果連起來寫的話是這樣:var nd=d3.svg.line()(data); 這樣獲得的nd纔是可以塞給path的d屬性的東西。把這個思想上的彎繞過來,又掌握1/3

以上三點轉過來以後,基本算理解d3背後的思路了,大約看文檔也可以獨立寫點東西出來了。d3的使用模式如下:

- step1:準備數據
- step2:創建dom
- step3:設置屬性

作者:ciga2011 , 來源

1. 前期工作

1.1 修改項目爲 Pods 目錄(可選)

// config/environment.js
let ENV = {
    modulePrefix: 'ember-d3-demo',
    podModulePrefix: 'ember-d3-demo/modules',
    // ...
  };
// .ember-cli
{
  "disableAnalytics": false,
  "usePods": true
}

1.2 依賴安裝

首先是可選安裝 typescript

ember install ember-cli-typescript@latest && yarn add [email protected]

安裝 d3, 由於上面是使用了 typescript,所以安裝命令變爲:

ember install ember-d3 && npm i --save @types/d3

如果沒有安裝 typescript 那就按照官方教程正常安裝即可:

ember install ember-d3 && yarn add --save-dev [email protected]

至此,關於 d3 的依賴安裝完畢,如果是非 ember octane 版本,這時候可以跳過下面的說明,繼續使用了。
由於octane 版本中修改了 component / controller / route 等改爲類的繼承與擴展。對於 component 來說就是組件的聲明週期不再是 didInsertElement ,而是變成了使用 modifier .就需要多一步的安裝:

ember install @ember/render-modifiers

注意:以後的 ember 版本可能會默認添加此 modifier

2. 選擇元素和綁定數據

使用 d3 創建 hello world 文本。

2.1 創建 d3/hello-world 組件

修改 handlerbars :

<p class="d3-hello" {{did-insert this.hello}}></p>

修改 component 邏輯文件

import Component from '@glimmer/component';
import { action } from '@ember/object';
import {select} from 'd3-selection';

interface D3HelloWorldArgs {}

export default class D3HelloWorld extends Component<D3HelloWorldArgs> {
    @action
    hello() {
        select(".d3-hello").text("HELLOWORLD BY D3")
    }
}

在路由中使用此組件:

{{!-- d3 route file --}}
<h2>d3-1 helloworld</h2>
<D3::HelloWorld />

此時運行文件即可以看到:
hello world

同樣的:

import Component from '@glimmer/component';
import { action } from '@ember/object';
import {select} from 'd3-selection';

interface D3HelloWorldArgs {}

export default class D3HelloWorld extends Component<D3HelloWorldArgs> {
    @action
    hello() {
        let p = select(".d3-hello").text("HELLOWORLD BY D3");
        
        // 修改此元素的樣式
        p.attr("title","helloWorld").style("color","lightblue")
    }
}

這樣就可以改變字體的 style 樣式了,併爲此 P 標籤添加了 title 屬性,雖然沒有什麼作用。
更多的關於 d3-selection 的 API 請查看鏈接。

2.2 使用 .datum() / .data() 綁定數據

同樣的創建 d3/bind-data 組件。

{{!-- d3/bind-data.hbs --}}
<p class="d3-bind" {{did-insert this.dataBind}}></p>
<p class="d3-bind" {{did-insert this.dataBind}}></p>
<p class="d3-bind" {{did-insert this.dataBind}}></p>
<p class="d3-bind" {{did-insert this.dataBind}}></p>

<p class="d3-bind2" {{did-insert this.dataBind2}}></p>
<p class="d3-bind2" {{did-insert this.dataBind2}}></p>
// d3/bind-data.ts
import Component from '@glimmer/component';
import { selectAll } from 'd3-selection';
import { action } from '@ember/object';

interface D3BindDataArgs { }

const STR = "DATABIND";
const ARR = ["落霞與孤鶩齊飛","秋水共長天一色"];
export default class D3BindData extends Component<D3BindDataArgs> {
    @action
    dataBind() {
        let p = selectAll('.d3-bind');
        p.datum(STR)
        p.text(function (d, i) {
            return `✨第 ${i} 個元素綁定的值是 ${d}✨`
        })
    }
    @action
    dataBind2() {
        let ps = selectAll(".d3-bind2");
        ps.data(ARR).text(function(d) {
            return d
        })
    }
}

同樣的,在路由中使用此組件:

{{!-- d3 route file --}}
<h2>d3-1 helloworld</h2>
<D3::HelloWorld />
<div class="dropdown-divider"></div>
<h2>d3-2 bind-data</h2>
<D3::BindData />

運行程序可以看到:
bind-data

3. 做一個簡單的圖表

先從最簡單的 柱狀圖 開始:

涉及到 HTML 5 中的 svg

svg 包含六種基本圖形:

  • 矩形 rect
  • 圓形
  • 橢圓
  • 線段
  • 折線
  • 多邊形

另外還有一種在 icon 中比較常見的:

  • 路徑

畫布中的所有圖形,都是由以上七種元素組成。

3.1 添加畫布

在上一章節中,只是簡單的使用 D3 添加文本元素,而添加 svg 的代碼是:

{{!-- d3/basic-histogram.hbs --}}
<h3>3.1 append svg to element</h3>
<div class="basic-svg-container" {{did-insert this.appendSvg}}></div>
// d3/basic-histogram.ts
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { select } from 'd3-selection';

interface D3BasicHistogramArgs {}

export default class D3BasicHistogram extends Component<D3BasicHistogramArgs> {
    @action
    appendSvg() {
        let container = select(".basic-svg-container");
        container.append('svg')
        .attr("width",200)
        .attr("height",123.6)
        .style("background-color","orange")
    }
}

簡單的向 div 元素中添加一個背景顏色爲 orange 的 svg:

svg container

有了畫布,就可以在畫布上進行作圖了:

3.2 繪製簡單柱狀圖

柱狀圖就是由一個個的 rect 元素組成:

{{!-- d3/basic-histogram.hbs --}}
<h3>3.1 append svg to element</h3>
<div class="basic-svg-container" {{did-insert this.appendSvg}}></div>
<h3>3.2 basic-histogram</h3>
<svg class="bar-container" {{did-insert this.initHistogram}}>
</svg>
// d3/basic-histogram.ts
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { select } from 'd3-selection';

interface D3BasicHistogramArgs { }

const DATASET = [250, 210, 170, 130, 90];  //數據(表示矩形的寬度);
const RECTHEIGHT = 25;   //每個矩形所佔的像素高度(包括空白)
export default class D3BasicHistogram extends Component<D3BasicHistogramArgs> {
    @action
    appendSvg() {
        let container = select(".basic-svg-container");
        container.append('svg')
            .attr("width", 300)
            .attr("height", 185.4)
            .style("background-color", "orange")
    }

    @action
    initHistogram() {
        const barContainer = select(".bar-container");

        barContainer
            .attr("width",300)
            .attr("heigt",185.4)
            .selectAll("rect")
            .data(DATASET)
            .enter()
            .append("rect")
            .attr("x", 20)
            .attr("y", function (d, i) {
                return i * RECTHEIGHT
            })
            .attr("width", function (d) {
                return d;
            })
            .attr("height", RECTHEIGHT - 2)
            .attr("fill", "#579AFF")

    }
}

其中,rect 元素的一些屬性說明

  • x 屬性定義矩形的左側位置(例如,x=“0” 定義矩形到瀏覽器窗口左側的距離是 0px)
  • y 屬性定義矩形的頂端位置(例如,y=“0” 定義矩形到瀏覽器窗口頂端的距離是 0px)

在路由中使用此插件即可以看到:
basic-histogram

這裏需要注意的代碼是:

barContainer
    .attr("width",300)
    .attr("heigt",185.4)
    .selectAll("rect")
    .data(DATASET)
    .enter()
  .append("rect")

其中 data() 方法將指定數組的數據 data 與已經選中的元素進行綁定並返回一個新的選擇集,返回的新的選擇集使用 update 表示: 此時數據已經成功的與元素綁定。

3.3 比例尺

在實際的繪製圖表的過程中,不可能像上述那樣,根據數值去直接展示長度,需要進行一步比例尺的轉換。

D3 提供了相關的比例尺轉化的 API 包括不限於: d3.scaleLineard3.scaleOrdinal 等等。像一般簡單的展示線性的比例尺的寫法是:

{{! d3/basic-histogram.hbs --}}
{{! ... --}}
<h3>3.3 scale</h3>
<svg class="scale" {{did-insert this.initScale}}>
     <rect></rect>
</svg>
// d3/basic-histogram.ts
// ...
@action
initScale() {
    const dataset = [2.5, 2.1, 1.7, 1.3, 0.9];

    let linear = scaleLinear()
    .domain([0, max(dataset,null)])
    .range([0, 250]);

    const barContainer = select(".scale");

    barContainer
        .attr("width", 300)
        .attr("heigt", 185.4)
        .selectAll("rect")
        .data(dataset)
        .attr("x", 20)
        .attr("y", function (d, i) {
        return i * RECTHEIGHT
    })
        .attr("width", function (d) {
        return linear(d);
    })
        .attr("height", RECTHEIGHT - 2)
        .attr("fill", "#C2DAFF")
    // 爲何重複設置 attr 見下文解釋
        .enter()
        .append("rect")
        .attr("x", 20)
        .attr("y", function (d, i) {
        return i * RECTHEIGHT
    })
        .attr("width", function (d) {
        return linear(d);
    })
        .attr("height", RECTHEIGHT - 2)
        .attr("fill", "#C2DAFF");
}
// ...

展示的效果和 3.2 中的圖片相同。

文中代碼中的解釋:

當svg 內已有元素時,會導致以後的元素不能正確顯示。這是因爲已經存在的元素,使用 data() 之後,如果數據個數超出已存在的元素的個數,超出的這部分稱之爲 enter ,元素與數據對應的這部分稱之爲 update。需要對 update 部分以及 enter 部分的元素分別設置各種 attr,來達到一同展示的目的。具體解釋可以查看

3.4 添加座標軸

在 d3 中有專門的座標軸相關的 API

同樣利用上節中相同的數據來生成圖表,並添加座標軸:

{{!-- d3/basic-histogram.hbs --}}
{{!-- ... --}}

<h3>3.4 coordinate</h3>
<svg class="coordinate" {{did-insert this.initAxes}}></svg>
// d3/basic-histogram.ts
// ...
@action
initAxes() {
    const linear = scaleLinear()
    .domain([0, max(dataset)])
    .range([0, 250]);


    let svgContainer = select(".coordinate");

    svgContainer
    // 樣式放入了類中進行控制
        .selectAll("rect")
        .data(dataset)
        .enter()
        .append("rect")
        .attr("x", 20)
        .attr("y", (d, i: number) => i * RECTHEIGHT)
        .attr("width", d => linear(d))
        .attr("height", RECTHEIGHT - 2)
        .attr("fill", "#FFC400");

    const axis = axisBottom(linear)
    .ticks(7);

    svgContainer.append("g")
        .attr("class","pb-tm-axis")
        .attr("transform","translate(20,130)").call(axis)

}
// ...
// 樣式文件
.coordinate {
    width: 300px;
    height: 185.4px;
}

.pb-tm-axis path,
.pb-tm-axis line {
    fill: none;
    stroke: #DFE1E6;
    shape-rendering: crispEdges;
}

.pb-tm-axis text {
    font-family: PingFangSC-Regular;
    font-size: 14px;
    color: #7A869A;
}

最後展示的效果:
coordinate

4. 完整的柱狀圖

histogram
這是仿照 ucb 中一個混合圖提取出來的柱狀圖。目前是純展示的圖表。

具體的實現是

{{!-- d3/bp-bar.hbs --}}
<div class="bp-bar" {{did-insert this.initBar}}></div>
// d3/bp-bar.ts
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { select } from 'd3-selection';
import { scaleLinear, scaleBand } from 'd3-scale';
import { max } from 'd3-array';
import { axisBottom, axisLeft } from 'd3-axis';

interface D3BpBarArgs { }

const DATASET = [
    ['2018Q1', 2263262.25, 2584466.75, "0.8757", "all", null],
    ['2018Q2', 2194822.96875, 2643496, "0.8303", "all", null],
    ['2018Q3', 2359731.25, 2770609.75, "0.8517", "all", null],
    ['2018Q4', 2165844.0625, 2914783.4375, "0.7431", "all", null],
    ['201Q91', 704715.671875, 2274136, "0.3099", "all", null],
    ['201Q92', 677539.40625, 2806879, "0.2414", "all", null],
    ['201Q93', 679346.203125, 2975934, "0.2283", "all", null]
]

export default class D3BpBar extends Component<D3BpBarArgs> {
    @action
    initBar() {
        // 聲明變量
        const svgContainer = select('.bp-bar');
        const width: number = Number(svgContainer.style("width").split("p")[0])
        const height: number = Number(svgContainer.style("height").split("p")[0])
        const padding = { top: 24, right: 24, bottom: 24, left: 84 }
        const barWidth = 16;
        /**
         * 添加 svg 畫布
         */
        const svg = svgContainer
            .append('svg')
            .attr("width", width)
            .attr("height", height);
        /**
         * x 軸的比例尺
         */
        let xAxisData = DATASET.map((ele: any[]): string => ele[0]);
        console.log(xAxisData)
        const xScale = scaleBand()
            .domain(xAxisData)
            .range([0, width - padding.left])
        /**
         * y 軸的比例尺
         */
        let yAxisData = DATASET.map((ele: Array<any>): number => ele[1])
        const yScale = scaleLinear()
            .domain([0, max(yAxisData)])
            .range([height - padding.top - padding.bottom, 0]);

        /**
         * 定義座標軸
         */
        let xAxis = axisBottom(xScale);
        let yAxis = axisLeft(yScale);

        /**
         * 添加柱狀圖
         */
        svg.selectAll('rect')
            .data(DATASET)
            .enter()
            .append('rect')
            .classed('bp-bar-rect', true)
            .attr("transform", `translate(${padding.left},${ padding.top})`)
            .attr('x', (d) => {
                return xScale(d[0]) + xScale.bandwidth() / 2 - barWidth / 2
            })
            .attr('y', (d) => yScale(d[1]))
            .attr('width', barWidth + "px")
            .attr('height', (d) => height - padding.top - padding.bottom - yScale(d[1]))
            .text((d: any) => d[4]);

        /***
         * 添加座標軸
         */
        svg.append('g')
            .classed('x-axis', true)
            .attr("transform", "translate(" + padding.left + "," + (height - padding.bottom) + ")")
            .call(xAxis);

        svg.append("g")
            .classed('y-axis', true)
            .attr("transform", "translate(" + padding.left + "," + padding.top + ")")
            .call(yAxis);

    }
}

5. 添加 transition 以及交互

直愣愣的圖表需要一些動態效果來使其變得更生動。交互則可以讓圖表表達更具體的信息。

transition

5.1 transition 的添加

transition 等動畫在 d3 中添加是很容易的,和 css3 中的動畫大相徑庭。以上面的柱狀圖爲例。

我們想要的效果就是在第一次加載柱狀圖的時候能有一個從底至上的一個動畫效果。

// d3/bp-bar.ts
// 。。。
/**
 * 添加柱狀圖
 */
	// svg.selectAll('rect')
	//     .data(DATASET)
	//     .enter()
	//     .append('rect')
	//     .classed('bp-bar-rect', true)
	//     .attr("transform", `translate(${padding.left},${ padding.top})`)
	//     .attr('x', (d) => {
	//         return xScale(d[0]) + xScale.bandwidth() / 2 - barWidth / 2
	//     })
	//     .attr('y', (d) => yScale(d[1]))
	//     .attr('width', barWidth + "px")
	//     .attr('height', (d) => height - padding.top - padding.bottom - yScale(d[1]))
	//     .text((d: any) => d[4]);

/**
 * 爲柱狀圖添加動畫
 */
const t = transition()
	.ease();

svg.selectAll('rect')
    .data(DATASET)
    .enter()
    .append('rect')
    .classed('bp-bar-rect', true)
    .attr("transform", `translate(${padding.left},${padding.top})`)
    .attr('x', (d) => {
    	return xScale(d[0]) + xScale.bandwidth() / 2 - barWidth / 2
	})
    .attr('y', height - padding.bottom-24) // 24 爲x座標軸的高度
    .attr('width', barWidth + "px")
    .attr('height',0)
    .transition(t)
    .duration(4000)
    .attr('y', (d) => yScale(d[1]))
    .attr('height', (d) => height - padding.top - padding.bottom - yScale(d[1]))
    .text((d: any) => d[4]);
// 。。。

主要的代碼是 transition() 這一行開始,在此行之前的狀態與之後的狀態通過此 API 進行動態展示。在此例子中將每個 rect 元素的 y 以及 height 經過 4000 ms 進行改變。

5.2 交互

svg.selectAll('rect')
    .on('mouseover', function (d, i: number) {
    	// 保證修改的元素的 fill 不是在 class 中
    	// 而是通過 attr('fill',value) 定義的
        select(this).attr("fill", "#FFC400")
    })
    .on('mouseout', function (d, i) {
        select(this)
            .transition()
            .duration(1000)
            .attr("fill", "#579AFF")
})

上述交互的意圖就是簡單的更換柱狀圖的顏色。所以當前柱狀圖的 fill 不能夠再寫在樣式類中,需要通過 attr 寫在當前。

6. layout - 餅圖

6.1 layout

在學習餅圖之前需要對 d3 中的 layout 有一定的瞭解。d3 的 layout 與 css 的 layout 是截然不同的兩個概念。

官方文章中對 layout 的定義:

Layout functions sets up and modifies data so that it can be rendered in different shapes. The layout functions don’t do the drawing of the shapes.

大概意思就是 layout 函數設置以及修改數據以便其可以適用於不同的圖形中。layout 函數不參與繪製圖形。

有了這層的概念,就可以來看 layout 用於 餅圖上的實例了。

6.2 餅圖

pie
我們要得到這樣的環形圖。

創建一個新的 component

ember g component d3/bp-pie

在其 handlebars 文件中:

<div class="bp-pie" {{did-insert this.initPie}}></div>

與之前的組件大致相似。

那其邏輯文件則是:

// d3/bp-pie.ts
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { select } from 'd3-selection';
import { tracked } from '@glimmer/tracking';
import { pie, arc } from 'd3-shape';
import { schemeCategory10 } from 'd3-scale-chromatic';

interface D3BpPieArgs {
    data: string | number[]
    // data: [
    //     ["癲癇競品1", 2575385.5, null, "0.1952"],
    //     ["開浦蘭", 679346.1875, null, "0.0515"],
    //     ["癲癇競品2", 279866.65625, null, "0.0212"],
    //     ["維派特", 0, null, "0.0000"],
    //     ["其他競品", 9662320.65625, null, "0.7322"]
    //   ]
}

export default class D3BpPie extends Component<D3BpPieArgs> {
    @tracked data = this.args.data
    get layoutData() {
        const pieLayout = pie()
            // 設置如何從數據中獲取要繪製的值
            .value((d: any) => d[1])
            // 設置排序規則 (null 表示原始排序)
            .sort(null)
            // 設置第一個數據的起始角度 (默認爲 0)
            .startAngle(0)
            // 設置弧度的終止角度,(默認 2*Math.PI)
            // endAngle - startAngle 爲 2 π 則表示一個整圓
            .endAngle(2 * Math.PI)
            // 弧度之間的空隙角度(默認 0)
            .padAngle(0);
        return pieLayout(this.data)
    }
    @action
    initPie() {
        const container = select('.bp-pie');
        // 聲明變量 
        const width: number = Number(container.style("width").split("p")[0])
        const height: number = Number(container.style("height").split("p")[0])
        const innerRadius = 84
        const outerRadius = 100
        // 生成 svg
        let svg = container.append('svg')
            .attr("width", width)
            .attr("height", height)

        const pieData = this.layoutData
        // 基礎 rect 設置
        let arcins = arc()
            .innerRadius(innerRadius)
            .outerRadius(outerRadius);

        // hover 狀態 rect 的設置
        let arcOver = arc()
            .innerRadius(innerRadius)
            .outerRadius(outerRadius + 15);

        svg.selectAll('path.arc')
            .data(pieData)
            .enter()
            .append('path')
            .attr("transform", "translate(" + (width / 2) + "," + (height / 2) + ")")
            .attr('fill', (d: any, i: number) => schemeCategory10[i])
            .classed('arc', true)
            .attr('d', arcins);

        svg.selectAll('path.arc')
            .on('mouseover', function () {
                select(this)
                    .transition()
                    .duration(1000)
                    .attr('d', arcOver)
            })
            .on('mouseout', function () {
                select(this)
                    .transition()
                    .duration(100)
                    .attr('d', arcins)
            })
    }
}

使用到 layout 的是在 layoutData 屬性上,使用的是 layout 的 pie() 函數。其中每行均有說明。返回的 pieLayout(this.Data) 的格式爲:

[{
	"data": ["癲癇競品1", 2575385.5, null, "0.1952"],
	"index": 0,
	"value": 2575385.5,
	"startAngle": 0,
	"endAngle": 1.2261668298428863,
	"padAngle": 0
}, {
	"data": ["開浦蘭", 679346.1875, null, "0.0515"],
	"index": 1,
	"value": 679346.1875,
	"startAngle": 1.2261668298428863,
	"endAngle": 1.5496103535766053,
	"padAngle": 0
}, {
	"data": ["癲癇競品2", 279866.65625, null, "0.0212"],
	"index": 2,
	"value": 279866.65625,
	"startAngle": 1.5496103535766053,
	"endAngle": 1.6828576715695005,
	"padAngle": 0
}, {
	"data": ["維派特", 0, null, "0.0000"],
	"index": 3,
	"value": 0,
	"startAngle": 1.6828576715695005,
	"endAngle": 1.6828576715695005,
	"padAngle": 0
}, {
	"data": ["其他競品", 9662320.65625, null, "0.7322"],
	"index": 4,
	"value": 9662320.65625,
	"startAngle": 1.6828576715695005,
	"endAngle": 6.283185307179586,
	"padAngle": 0
}]

可以看到,獲得的數組中每個 item 都是一個對象,用來描述每一個弧度(rect)。其中包括:

  • 原始數據 data
  • 數據下標 index
  • 根據 value() 方法設置的需要展示的值 value
  • 本 rect 的開始角度 startAngle
  • 本 rect 的結束角度endAngle
  • 與其後 rect 的間隙角度大小 padAngle

添加的事件展示出的動態效果即:

在這裏插入圖片描述

6.3 折線圖

6.3.1 單折線

創建一個組件來展示折線圖:

ember g component d3/bp-line

同樣的修改 hbs 文件:

<div class="bp-line" {{did-insert this.initLine}}></div>

先直接來 ts 的代碼:

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { select } from 'd3-selection';
import { scaleBand, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { max } from 'd3-array';
import { line, curveCatmullRom } from 'd3-shape';

interface D3BpLineArgs {
    data: any[]
    /**
     * 單折線數據示例
     */
    //  [
    //     ['2018Q1', 2263262.25, 2584466.75, "0.8757", "all", null],
    //     ['2018Q2', 2194822.96875, 2643496, "0.8303", "all", null],
    //     ['2018Q3', 2359731.25, 2770609.75, "0.8517", "all", null],
    //     ['2018Q4', 2165844.0625, 2914783.4375, "0.7431", "all", null],
    //     ['201Q91', 704715.671875, 2274136, "0.3099", "all", null],
    //     ['201Q92', 677539.40625, 2806879, "0.2414", "all", null],
    //     ['201Q93', 679346.203125, 2975934, "0.2283", "all", null]
    // ]
    width: number
    height: number
    layout: any // TODO用於控制 div 的佈局 {h:**,w:**,x:**,y:**}
}

export default class D3BpLine extends Component<D3BpLineArgs> {
    private width: number | string = "100%"
    private height: number | string = "100%"
    // 動畫函數
    private tweenDash() {
        let l = this.getTotalLength(),
            i = interpolateString("0," + l, l + "," + l);
        return function (t:any) { return i(t); };
    }
    @action
    initLine() {
        const dataset = this.args.data
        const container = select(".bp-line")
        this.width = parseInt(container.style("width"))
        this.height = parseInt(container.style("height"))
        const padding = {
            top: 24,
            right: 24,
            bottom: 24,
            left: 24
        }
        const svg = container.append('svg')
            .attr("width", this.width)
            .attr('height', this.height)
            .style('background-color', "#fafbfc");

        const yScale = scaleLinear()
            .domain([0, max(dataset.map((ele: any[]) => ele[1]))])
            .range([this.height - padding.top - padding.bottom, 0]);

        const yAxis = axisLeft(yScale)

        svg.append('g')
            .classed('y-axis', true)
            .call(yAxis)

        // 動態獲取y座標軸的寬度
        const yAxisWidth: number = svg.select('.y-axis').node().getBBox().width;

        svg.select(".y-axis")
            .attr("transform", `translate(${padding.left + yAxisWidth},${padding.top})`)


        // 最後繪製 x 座標軸,可以根據y軸的寬度動態計算 x軸所佔的寬度
        const xScale = scaleBand()
            .domain(dataset.map((ele: any[]) => ele[0]))
            .range([padding.left, this.width - padding.right - yAxisWidth])

        const xAxis = axisBottom(xScale)
        svg.append('g')
            .classed('x-axis', true)
            .attr("transform", `translate(${yAxisWidth},${this.height - padding.bottom})`)
            .call(xAxis);

        const lineLayout = line().x((d: any) => xScale(d[0]))
            .y((d: any) => yScale(d[1]))
            // 添加彎曲度
            // https://bl.ocks.org/d3noob/ced1b9b18bd8192d2c898884033b5529
            // 上述鏈接展示參數的不同,線條會有怎樣的變化
            .curve(curveCatmullRom.alpha(0.5))

		// 單折線的數據展示方式-1
        /**
         svg.append('g')
             .append('path')
             .classed('line-path', true)
             .attr('transform', `translate(${padding.left + yAxisWidth},${padding.top})`)
             .attr('d', lineLayout(dataset))
             .attr('fill', 'none')
             .attr('stroke-width', 2)
             .attr('stroke', '#FFAB00');
        */
        // 單折線的數據展示方式-2
        svg.append("path")
            .datum(dataset)
            .classed('line-path', true)
            .attr('transform', `translate(${padding.left + yAxisWidth},${padding.top})`)
            .attr("d", lineLayout)
            .attr('fill', 'none')
            .attr('stroke-width', 2)
            .attr('stroke', '#FFAB00');

        // 添加初始動畫
        svg.select('.line-path')
            .transition()
            .duration(4000)
            .attrTween("stroke-dasharray", this.tweenDash);
        let circles = svg.append('g')
            .selectAll('circle')
            .data(dataset)
            .enter()

        circles.append('circle')
            .attr('r', 3)
            .attr('transform', function (d) {
                return 'translate(' + (xScale(d[0]) + padding.left + yAxisWidth) + ',' + (yScale(d[1]) + padding.top) + ')'
            })
            .attr('stroke', '#FFAB00')
            .attr('fill', 'white')
            .on('mouseover', function () {
                select(this)
                    .transition()
                    .duration(600)
                    .attr('r', 6)
            })
            .on('mouseout', function () {
                select(this)
                    .transition()
                    .duration(600)
                    .attr('r', 3)
            })

    }
}

繪製方法大相徑庭。無非是畫座標軸,設置折線展示的數據,以及添加其他樣式或動畫效果等。

這裏有幾點需要注意的地方:

  1. 座標軸的動態計算寬度以及根據寬度進行偏移.查看更多
  2. 折線彎曲度的參數選擇 各參數對摺線的影響
  3. 單折線圖的數據添加方式(兩種)

最後的成果展示:
chart line

⚠️注意因數據格式與代碼耦合嚴重,後期要修改折線數據結構。

如果細心觀察的話,會發現其實每個點和 x 軸座標上的點並不是對齊的,這是因爲我們在創建 x 軸座標軸的時候,選擇的是 scaleBand 的方式:
scaleBand

通過上圖我們可以得知爲什麼會出現這樣的問題,如果還是使用此方法創建座標軸,那就需要移動 1/2 的 bandWidth 的寬度,來使其對齊:

相關代碼:

// 。。。 
// 單折線的數據展示方式-1
/**
	svg.append('g')
		.append('path')
		.classed('line-path', true)
		.attr('transform', `translate(${yAxisWidth+xScale.bandwidth()/2},${padding.top})`)
             .attr('d', lineLayout(dataset))
             .attr('fill', 'none')
             .attr('stroke-width', 2)
             .attr('stroke', '#FFAB00');
        */
// 單折線的數據展示方式-2
svg.append("path")
    .datum(dataset)
    .classed('line-path', true)
    .attr('transform', `translate(${ yAxisWidth+xScale.bandwidth()/2},${padding.top})`)
    .attr("d", lineLayout)
    .attr('fill', 'none')
    .attr('stroke-width', 2)
    .attr('stroke', '#FFAB00');
//。。。
circles.append('circle')
    .attr('r', 3)
    .attr('transform', function (d) {
    return 'translate(' + (xScale(d[0]) +xScale.bandwidth()/2 + yAxisWidth) + ',' + (yScale(d[1]) + padding.top) + ')'
})
    .attr('stroke', '#FFAB00')
    .attr('fill', 'white')

主要注意 transform 屬性的改變。

6.3.2 多折線

實際效果:
multi-lines

不多說,直接上代碼:

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { select } from 'd3-selection';
import { scaleBand, scaleTime, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { max, min } from 'd3-array';
import { line, curveCatmullRom } from 'd3-shape';
import { interpolateString } from 'd3-interpolate';
import { schemeCategory10 } from 'd3-scale-chromatic';
// import { timeMonth } from 'd3-time';
// import { timeFormat } from 'd3-time-format'

interface D3BpMultiLinesArgs {
    data: any[]
    /**
     * 多折線數據示例
     */
    // [[
    //     {label:'2018-01', name:"開浦蘭",value:0.715,count: 2300, other:0.0000},
    //     {label:'2018-04', name:"開浦蘭",value:0.663,count: 2400, other:0.0000},
    //     {label:'2018-07', name:"開浦蘭",value:0.18,count: 2300, other:0.0000},
    //     {label:'2018-10', name:"開浦蘭",value:0.3788,count: 2300, other:0.0000}
    // ],
    // [
    //     {label:'2018-01', name:"癲癇競品1",value:0.15,count: 2100, other:0.0000},
    //     {label:'2018-04', name:"癲癇競品1",value:0.63,count: 2400, other:0.0000},
    //     {label:'2018-07', name:"癲癇競品1",value:0.18,count: 200, other:0.0000},
    //     {label:'2018-10', name:"癲癇競品1",value:0.78,count: 300, other:0.0000}
    // ],
    // [
    //     {label:'2018-01', name:"維派特",value:0.5,count: 100, other:0.0000},
    //     {label:'2018-04', name:"維派特",value:0.3,count: 400, other:0.0000},
    //     {label:'2018-07', name:"維派特",value:0.1,count: 2500, other:0.0000},
    //     {label:'2018-10', name:"維派特",value:0.7,count: 3100, other:0.0000}
    // ]]
    width: number
    height: number
    layout: any // TODO用於控制 div 的佈局 {h:**,w:**,x:**,y:**}
}
interface D3BpMultiLinesArgs { }

export default class D3BpMultiLines extends Component<D3BpMultiLinesArgs> {
    private width: number | string = "100%"
    private height: number | string = "100%"
    // 動畫函數
    private tweenDash() {
        let l = this.getTotalLength(),
            i = interpolateString("0," + l, l + "," + l);
        return function (t: any) { return i(t); };
    }
    @action
    initLine() {
        const dataset = this.args.data
        const container = select(".bp-multiline")
        this.width = parseInt(container.style("width"))
        this.height = parseInt(container.style("height"))
        const padding = {
            top: 24,
            right: 24,
            bottom: 24,
            left: 24
        }
        const svg = container.append('svg')
            .attr("width", this.width)
            .attr('height', this.height)
            .style('background-color', "#fafbfc");

        const yScale = scaleLinear()
            .domain([0, max(dataset.flat().map((ele: any[]) => ele['value']))])
            .range([this.height - padding.top - padding.bottom, 0]);

        const yAxis = axisLeft(yScale)

        svg.append('g')
            .classed('y-axis', true)
            .call(yAxis)

        // 動態獲取y座標軸的寬度
        const yAxisWidth: number = svg.select('.y-axis').node().getBBox().width;

        svg.select(".y-axis")
            .attr("transform", `translate(${padding.left + yAxisWidth},${padding.top})`)

        // 最後繪製 x 座標軸,可以根據y軸的寬度動態計算 x軸所佔的寬度
        /**
         * 爲了 scaleTime
            let xLabel = dataset[0].map((ele: any) => ele['label'])
            let minXvalue = new Date(min(xLabel))
            let maxXvalue = new Date(max(xLabel))
         */
        
        const xScale = scaleBand()
            .domain(dataset[0].map((ele: any[]) => ele['label']))
            // 爲了 scaleTime
            // .domain([minXvalue, maxXvalue])
            .range([padding.left, this.width - padding.right - yAxisWidth]);

        const xAxis = axisBottom(xScale)
        // 爲了 scaleTime
        // .ticks(timeMonth.every(3))
        // .tickFormat(timeFormat("%YQ%q"))

        svg.append('g')
            .classed('x-axis', true)
            .attr("transform", `translate(${yAxisWidth},${this.height - padding.bottom})`)
            .call(xAxis);

        const lineLayout = line().x((d: any[]) => xScale(d['label']))
            .y((d: any) => yScale(d['value']))
            .curve(curveCatmullRom.alpha(0.5))
        
        // 多折線的數據展示方式-1
        dataset.forEach((data: any, index: number) => {
            svg.append("path")
                .datum(data)
                .classed('line-path', true)
                .attr('transform', `translate(${yAxisWidth + xScale.bandwidth() / 2},${padding.top})`)
                .attr("d", lineLayout)
                .attr('fill', 'none')
                .attr('stroke-width', 2)
                .attr('stroke', () => schemeCategory10[index]);

            let circles = svg.append('g')
                .selectAll('circle')
                .data(data)
                .enter()

            circles.append('circle')
                .attr('r', 3)
                .attr('transform',  (d:any) =>`translate( ${xScale(d['label']) +  yAxisWidth+xScale.bandwidth() / 2},${yScale(d['value']) + padding.top})`)
                .attr('stroke', schemeCategory10[index])
                .attr('fill', 'white')
                .on('mouseover', function () {
                    select(this)
                        .transition()
                        .duration(600)
                        .attr('r', 6)
                })
                .on('mouseout', function () {
                    select(this)
                        .transition()
                        .duration(600)
                        .attr('r', 3)
                })
        })
        // TODO 其他展現形式
        // svg.selectAll('path')
        //     .data(dataset)
        //     .enter()
        //     .append('path')
        //     .classed('.line-path', true)
        //     .attr('d',  (d:any)=>lineLayout(d))
        //     .attr('transform', `translate(${padding.left + yAxisWidth},${padding.top})`)
        //     .style('stroke', d => 'red')
        //     .style('stroke-width', 2)
        //     .style('fill', 'transparent')// 單折線的數據展示方式-1
        /**
         svg.append('g')
             .append('path')
             .classed('line-path', true)
             .attr('transform', `translate(${padding.left + yAxisWidth},${padding.top})`)
             .attr('d', lineLayout(dataset))
             .attr('fill', 'none')
             .attr('stroke-width', 2)
             .attr('stroke', '#FFAB00');
        */
        // 添加初始動畫
        svg.selectAll('.line-path')
            .transition()
            .duration(4000)
            .attrTween("stroke-dasharray", this.tweenDash);

    }
}

主要是數據格式進行了一些修改。

還嘗試使用了 scaleTime 來生成 x 軸。

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