D3數據可視化-餅圖-pie

Demo

餅圖/環形圖可以清晰地展示數據之間的比例關係。下面兩個環形圖分別展現了電子遊戲 GTAV 在各個遊戲硬件平臺上和在不同國家的銷售量。

我們的源數據是這樣的:

數據來自 vgcharts

可以看到相對於表格數據,餅圖可以更容易讓人察覺到數據要傳達的意義。

解析

這樣的餅圖,可以分成三個組成部分:環形(Arc)、文字標籤(Label)和連接環形和標籤之間的連線(Line)。

Arc

SVG 的 path 元素可以幫助我們在瀏覽器中渲染出環形,描述形狀的 d 屬性需要使用 d3.arc 來生成。爲了生成一個環形,我們需要四個參數:開始角度(startAngle),結束角度(endAngle),內圓半徑長(innerRadius),外圓半徑長(outerRadius)。當內圓半徑爲0的時候生成的圖形是餅,不爲0的時候則是環形。內外圓的半徑長度我們指定爲自己想要的任意長度,但是開始和結束的角度是根據給出的數據計算出來的,d3.pie 可以根據輸入的數據生成這些角度數據。

假設們有這樣一個數組描述數據:

var data = [1, 1, 2, 3, 5, 8, 13, 21];

d3.pie 可以把這個數據轉化爲扇形的角度數據

var generator = d3.pie().value((d) => d);
var slices = generator(data);

其中 value 是一個取值函數,也就是 accessor,如果單個數據使用對象(object)來描述,那麼這個函數可以指定取 object 裏面的哪個值用於這個扇形的角度。

轉化之後的數據爲這樣的結構:

[
  {"data":  1, "value":  1, "index": 6, "startAngle": 6.050474740247008, "endAngle": 6.166830023713296, "padAngle": 0},
  {"data":  1, "value":  1, "index": 7, "startAngle": 6.166830023713296, "endAngle": 6.283185307179584, "padAngle": 0},
  {"data":  2, "value":  2, "index": 5, "startAngle": 5.817764173314431, "endAngle": 6.050474740247008, "padAngle": 0},
  {"data":  3, "value":  3, "index": 4, "startAngle": 5.468698322915565, "endAngle": 5.817764173314431, "padAngle": 0},
  {"data":  5, "value":  5, "index": 3, "startAngle": 4.886921905584122, "endAngle": 5.468698322915565, "padAngle": 0},
  {"data":  8, "value":  8, "index": 2, "startAngle": 3.956079637853813, "endAngle": 4.886921905584122, "padAngle": 0},
  {"data": 13, "value": 13, "index": 1, "startAngle": 2.443460952792061, "endAngle": 3.956079637853813, "padAngle": 0},
  {"data": 21, "value": 21, "index": 0, "startAngle": 0.000000000000000, "endAngle": 2.443460952792061, "padAngle": 0}
]

其中 index 指的是該數據在原數組中的 index。

接下來再指定內外徑就可以生成環形了。

var arc = d3.arc().innerRadius([radius]).outerRadius([outerRadius]);

對這個 arc generator 傳入之前獲取的角度屬性即可得到 path 的 d 屬性。

var d = arc(slices[0]);

以上這個例子來源於官方文檔

Line

連線和標籤的位置有根據不同的設計有不同的佈局方法,這裏採用的方式是在環形之外再計算一個不可見的環形,通過連接兩個環形的圖形畫出一段延伸線,再根據這個環處在圓的左半邊還是右半邊,向外水平延伸一段距離。示意圖如下:

外部的環在最後的實現中是不可見的,這裏畫出來體現開發思路。

連線由三個部分組成,起點、轉折點和終點。起點是可見環的圖心,轉折點是外部不可見環的圖心,終點是從轉折點向外水平延伸一段距離得到的。

判斷環在左半圓還是右半圓可以通過中間角來判斷

var midAngle = (startAngle + endAngle) / 2;

因爲一個整圓的弧度是 2π,所以如果 midAngle 在 [0, π],那麼我們可以判斷環在左半邊圓,如果在 (π, 2π] 這個範圍那麼在右半邊圓。

Label

標籤的位置根據轉折點而定,其中一個細節要注意的是文字的對齊方式 (text-anchor),左半圓環形的標籤應該右對齊,右半邊圓環形應該左對齊。

在某些數據情況下,可能會存在相互遮蓋的標籤,這時候我們需要做設計防重合的設計,一個比較簡單的思路是:如果連續兩個標籤在同一個半圓(都在左或者都在右),並且高度距離只差小於字體高度,那麼把後一個標籤向外延伸上一個標籤的長度。這個設計並沒有在下面的實現中體現,有時間的話讀者可以自行嘗試。

實現

下面是完整源碼實現,數據源存儲在另外的 csv 文件裏面,這裏通過 d3.csv 讀取之後,再在 callback 函數裏面渲染。github 地址在這裏查看。

<!DOCTYPE html>
<html>
  <body>
    <script src="http://d3js.org/d3.v5.min.js"></script>
    <script type="text/javascript">

      // Sales number of video game GTAV
      // Data visualization in two pie charts

      const PLATFORM = {
        PS4: 'PS4',
        PS3: 'PS3',
        PC: 'PC',
        XBox360: 'XBOX360',
        XBoxOne: 'XBoxOne',
      };

      const REGION = {
        NA: 'NA',
        PAL: 'PAL',
        JP: 'JP',
        OTHER: 'OTHER',
      }

      function getPie(data = []) {
        // console.log('data', data);

        const width = 600;
        const height = 400;
        const outerRadius = 120;
        const innerRadius = 80;
        const pivotRadius = 160;

        // const colorArray = ['red', 'green', 'blue', 'yellow'];
        const colorArray = [
          '#204A87',
          '#EF2928',
          '#9ADE00',
          '#0084C8'
        ]

        function getMidAngle(d) {
          return (d.endAngle + d.startAngle) / 2;
        }

        const svg = d3.select('body')
          .append('svg')
          .attr('width', width)
          .attr('height', height)

        svg.append('g').attr('class', 'slices')
        svg.append('g').attr('class', 'lines')
        svg.append('g').attr('class', 'labels')

        const overallTotal = data.reduce((accu, curr) => accu + curr.total || 0, 0);
        const formattedOverallTotal = Math.floor(100 * overallTotal) / 100;
        svg.append('text').text(`${formattedOverallTotal} m`).attr('transform', `translate(${width / 2}, ${height / 2})`).attr('text-anchor', 'middle')

        const getValue = (d) => {
          return d.total;
        };

        const pie = d3.pie().value(getValue);
        const slices = pie(data);
        const innerArc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius);

        const slice = svg.select('.slices').selectAll('path').data(slices);
        slice.enter()
          .append('path')
          .attr('transform', `translate(${width / 2}, ${height / 2})`)
          .attr('d', (d, i) => innerArc(slices[i]))
          .attr('fill', (d, i) => colorArray[i % (colorArray.length)]);

        slice.exit().remove();

        const endPoints = [];
        const pivotArc = d3.arc().innerRadius(outerRadius).outerRadius(pivotRadius);
        const line = svg.select('.lines').selectAll('polyline').data(slices);
        line.enter()
          .append('polyline')
          .attr('points', (d, i) => {
            const slice = slices[i];

            const innerCentroid = innerArc.centroid(slice);
            const x1 = innerCentroid[0] + width / 2;
            const y1 = innerCentroid[1] + height / 2;

            const pivotPoint = pivotArc.centroid(slice);
            const x2 = pivotPoint[0] + width / 2;
            const y2 = pivotPoint[1] + height / 2;

            const midAngle = getMidAngle(slice);
            const x3 = x2 + (midAngle > Math.PI ? -20 : 20);
            const y3 = y2;

            endPoints[i] = [x3, y3];

            return `${x1},${y1} ${x2},${y2} ${x3},${y3}`;
          })
          .attr('fill', 'none')
          .attr('stroke', (d, i) => colorArray[i % (colorArray.length)]);

        line.exit().remove();

        const label = svg.select('.labels').selectAll('text').data(slices);
        label.enter()
          .append('text')
          .text((d) => {
            const value = d.value;
            const label = d.data.label;
            return `${label}: ${value} m`;
          })
          .attr('transform', (d, i) => {
            const x = endPoints[i][0] + (getMidAngle(d) > Math.PI ? -10 : 10);
            const y = endPoints[i][1] + 5;

            return `translate(${x}, ${y})`;
          })
          .attr('text-anchor', (d) => {
            const midAngle = getMidAngle(d);
            return midAngle > Math.PI ? 'end' : 'start';
          });

        label.exit().remove();
      }

      function visualize(data) {
        const regionData = Object.keys(REGION).map((region) => {
          const total = data.map((datum) => datum.sales[region]).reduce((accu, curr) => accu + curr, 0);

          return {
            region,
            total: Math.floor(total * 100) / 100,
            label: region,
          };
        });

        const platformData = data.map((d) => {
          let total;
          if (d.total) {
            total = d.total;
          } else {
            total = Object.values(d.sales).reduce((accu, curr) => accu + curr, 0);
          }
          return {
            ...d,
            total: Math.floor(100 * total) / 100,
            label: d.platform,
          };
        });

        getPie(platformData, 'pie1');
        getPie(regionData, 'pie2');
      }

      // Need to start a local file server for serving this file due to the security policy of web browser
      d3.csv('./GTAV.csv', (row) => {
        const dataObject = {
          total: parseFloat(row['TOTAL']) || 0,
        };

        return {
          platform: row.platform,
          sales: Object.keys(REGION).reduce((accu, region) => {
            return {
              ...accu,
              [region]: parseFloat(row[region]),
            };
          }, dataObject),
        };
      })
      .then(visualize);

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