案例
與餅圖相似的是,stack bar 圖也適合表現各個項目在總體中的比例。但同時 stack bar 還可以表現隨着時間的推移各個項目比例的變化情況,這是餅圖難以做到的。
下圖展現了從2017年2季度到2018年1季度全球手機市場中各廠商的佔有率情況,數據來源於 IDC。
解析
普通的柱形圖,一個柱子就是一個 rect 元素。而在 stack bar 中,每個柱子都是由多個 rect 組成的,每個 rect 我們需要知道其 y座標軸上的基點(baseline)和頂點(topline)。我們的原始數據如下:
const data = [
{
year: 2017,
quarter: 2,
samsung: 0.229,
apple: 0.118,
huawei: 0.110,
oppo: 0.08,
xiaomi: 0.062,
others: 0.401,
},
{
year: 2017,
quarter: 3,
samsung: 0.221,
apple: 0.124,
huawei: 0.104,
oppo: 0.081,
xiaomi: 0.075,
others: 0.396,
},
...
]
我們需要把這些數據轉化爲相應的 series,結構如下:
[
[[0,0.118],[0,0.124],[0,0.196],[0,0.157]], // apple
[[0.118,0.347],[0.124,0.345],[0.196,0.385],[0.157,0.392]], // samsung
...
]
每個 series 代表一個屬性在各個 x 軸點上的範圍,每個數組元素的兩個取值分別是 baseline 和 topline。不過現在這裏的每個點代表的是值範圍,如果我們要實際繪製圖形,需要把值 scale 到實際圖形的長度。假設一段數據的值範圍是 [0, 0.118],而 y 軸的總長度是 100px,那麼這一段數據渲染出來的實際y軸範圍就是 [0, 11.8px]。
在柱形繪製完成之後,我們還需要添加 x 軸,可以用 scalePoint 幫助我們定位到相應的刻度(ticks)。需要注意的是,有些例子會使用 scaleBand 來定位 x 軸位置,但是 scaleBand 是不能指定柱寬度的,所以如果要讓柱形和x軸刻度對齊,最好使用 scalePoint 來定位,scalePoint 是寬度爲 0 的 scaleBand。
實現
源代碼 Git 地址在這裏。
完整實現如下:
<!DOCTYPE html>
<html>
<body>
<style>
svg {
border: 1px solid lightgrey;
}
.caption {
margin-top: 20px;
width: 600px;
text-align: center;
}
</style>
<script src="http://d3js.org/d3.v5.min.js"></script>
<script type="text/javascript">
const maxHeight = 400;
const maxWidth = 600;
const barWidth = 20;
const data = [
{
year: 2017,
quarter: 2,
samsung: 0.229,
apple: 0.118,
huawei: 0.110,
oppo: 0.08,
xiaomi: 0.062,
others: 0.401,
},
{
year: 2017,
quarter: 3,
samsung: 0.221,
apple: 0.124,
huawei: 0.104,
oppo: 0.081,
xiaomi: 0.075,
others: 0.396,
},
{
year: 2017,
quarter: 4,
samsung: 0.189,
apple: 0.196,
huawei: 0.107,
oppo: 0.069,
xiaomi: 0.071,
others: 0.368,
},
{
year: 2018,
quarter: 1,
samsung: 0.235,
apple: 0.157,
huawei: 0.118,
oppo: 0.074,
xiaomi: 0.084,
others: 0.332,
},
]
const keys = ['apple', 'samsung', 'huawei', 'oppo', 'xiaomi', 'others'];
const getTimePoint = (d) => {
const _d = d.data ? d.data : d;
return `${_d.year}-${_d.quarter}`;
}
const stack = d3.stack().keys(keys).order(d3.stackOrderNone).offset(d3.stackOffsetNone);
const series = stack(data);
const colorArray = ['#38CCCB', '#0074D9', '#2FCC40', '#FEDC00', '#FF4036', 'lightgrey'];
function renderVerticalStack() {
const svg = d3.select('body')
.append('svg')
.attr('width', maxWidth)
.attr('height', maxHeight + 50);
const xScale = d3.scalePoint()
.domain(data.map(getTimePoint))
.range([0, maxWidth])
.padding(0.2);
const xScalePoint = d3.scalePoint()
.domain(data.map(getTimePoint))
.range([0, maxWidth])
.padding(0.2)
const stackMax = (serie) => d3.max(serie, (d) => d ? d[1] : 0)
const stackMin = (serie) => d3.min(serie, (d) => d? d[0]: 0)
const y = d3.scaleLinear()
.domain([d3.max(series, stackMax), d3.min(series, stackMin)])
.range([0, maxHeight])
const g = svg.selectAll('g')
.data(series)
.enter()
.append('g')
.attr('fill', (d, i) => colorArray[i % colorArray.length])
.selectAll('rect')
.data((d) => d)
.enter()
.append('rect')
.attr('x', (d) => {
const scaledX = xScale(getTimePoint(d));
return scaledX - barWidth / 2;
})
.attr('y', (d) => y(d[1]))
.attr('width', barWidth)
.attr('height', (d) => {
return y(d[0]) - y(d[1])
})
const axis = d3.axisBottom(xScalePoint);
svg.append('g')
.attr('transform', `translate(0, ${maxHeight})`)
.call(axis)
}
renderVerticalStack()
d3.select('body')
.append('div')
.style('width', maxWidth + 'px')
.style('display', 'flex')
.style('justify-content', 'space-around')
.selectAll('.legend')
.data(keys)
.enter()
.append('div')
.attr('class', 'legend')
.text((d) => d)
.style('color', (d, i) => colorArray[i % colorArray.length])
</script>
<div class='caption'>
Worldwide Top 5 Smartphone Shipment Company Market Share<br/>
Data Source: <a href='https://www.idc.com/promo/smartphone-market-share/vendor'>IDC</a>
</div>
</body>
</html>