上一篇講了如何在 React 項目中用 Vega-Lite 繪製基本的 area chart 圖表。
本篇將介紹如何繪製多層圖表,如何添加圖例。
多層圖表
通過上一篇文章,我們知道了可以通過 mark
, encoding
等來描述我們想要的圖表。要實現多層圖表,只需要把多個包含上述屬性的圖表對象放進 layer
數組中就可以。就像棧一樣, 從棧頂壓入,後壓入的(index 大的)圖層在上層。
我們在之前的數據中加入用戶評論數量 “user_comments”:
"data": {
"values": [
{ "user_comments": 0, "active_users": 0, "date": "2019-10-01" },
{ "user_comments": 3, "active_users": 2, "date": "2019-10-02" },
{ "user_comments": 1, "active_users": 0, "date": "2019-10-03" },
{ "user_comments": 1, "active_users": 1, "date": "2019-10-04" },
{ "user_comments": 2, "active_users": 0, "date": "2019-10-05" },
{ "user_comments": 1, "active_users": 0, "date": "2019-10-06" },
{ "user_comments": 2, "active_users": 1, "date": "2019-10-07" }
]
},
按照與上篇文章案例相同的 Vega-Lite 語法,寫一個描述 user_comments 的單層圖表。
其實只需要替換部分 y 軸的信息即可。
{
"mark": {"type": "area", "color": "#e0e0e0", "interpolate": "monotone"},
"encoding": {
"x":{
"field": "date",
"type": "ordinal",
"timeUnit": "yearmonthdate",
"axis": {"title": "Date", "labelAngle": -45}
},
"y": {
"field": "user_comments",
"type": "quantitative",
"axis": {
"title": "User Comments",
"format": "d",
"values": [1,2,3]
}
}
}
}
接下來,創建 layer
數組。把上述對象放入數組中,圖表沒有任何變化,此時仍然是單層圖表。
...
"layer":[
{
"mark": {"type": "area", "color": "#e0e0e0", "interpolate": "monotone"},
"encoding": {
"x":{
"field": "date",
"type": "ordinal",
"timeUnit": "yearmonthdate",
"axis": {"title": "Date", "labelAngle": -45}
},
"y": {
"field": "user_comments",
"type": "quantitative",
"axis": {
"title": "User Comments",
"format": "d",
"values": [1,2,3]
}
}
}
}
],
...
把上一篇中 Active Users 的對象加入數組,列在 User Comments 之後:
"layer":[
{
"mark": {"type": "area", "color": "#e0e0e0", "interpolate": "monotone"},
"encoding": {
"x":{
"field": "date",
"type": "ordinal",
"timeUnit": "yearmonthdate",
"axis": {"title": "Date", "labelAngle": -45}
},
"y": {
"field": "user_comments",
"type": "quantitative",
"axis": {
"title": "User Comments",
"format": "d",
"values": [1,2,3]
}
}
}
},
{
"mark": {"type": "area", "color": "#0084FF", "interpolate": "monotone"},
"encoding": {
"x": {
"field": "date",
"type": "ordinal",
"timeUnit": "yearmonthdate",
"axis": {"title": "Date", "labelAngle": -45}
},
"y": {
"field": "active_users",
"type": "quantitative",
"axis": {
"title": "Active Users",
"format": "d",
"values": [1,2]
}
}
}
}
],
噹噹~ 多層圖表出現了。
增加圖例
與之前的圖表相比,橫軸沒什麼變化,豎軸的位置顯示了兩層圖表的 title。但這樣表意不夠清晰,用戶不能一眼看明白哪個顏色代表哪個數據。所以我們需要引進圖例(legend)。
創建圖例的方式並不唯一,我通過 stroke
創建圖例,用 legend
來優化它的樣式。
在任一圖層中加入 stroke
:
...
{
"mark": {"type": "area", "color": "#e0e0e0", "interpolate": "monotone"},
"encoding": {
"x":{
"field": "date",
"type": "ordinal",
"timeUnit": "yearmonthdate",
"axis": {"title": "Date", "labelAngle": -45}
},
"y": {
"field": "user_comments",
"type": "quantitative",
"axis": {
"title": "User Comments",
"format": "d",
"values": [1,2,3]
}
},
"stroke": {
"field": "symbol",
"type": "ordinal",
"scale": {
"domain": ["User Comments", "Active Users"],
"range": ["#e0e0e0", "#0084FF"]
}
}
}
},
...
圖中出現了醜醜的圖例:
化妝師 legend
登場,趕緊打扮一下。在頂層的 config
中添加 legend
對象:
...
"legend": {
"offset": -106, // 調節圖例整體水平移動距離
"title": null,
"padding": 5,
"strokeColor": "#9e9e9e",
"strokeWidth": 2,
"symbolType": "stroke",
"symbolOffset": 0,
"symbolStrokeWidth": 10,
"labelOffset": 0,
"cornerRadius": 10,
"symbolSize": 100,
"clipHeight": 20
}
現在順眼多啦!
其實現在不要豎軸的 title 都可以,將 y.axis
對象的 title
刪除或置空即可,效果如文章首圖。
當圖層多的時候,也可以搭配使用 area chart 和 line chart,效果也不錯,只需要把該圖層的 mark.type
改爲 line
即可。
示意圖:
在 React 項目中使用
import React from 'react';
import { Vega } from 'react-vega';
// chart config
const jobpalBlue = '#e0e0e0';
const jobpalLightGrey = '#0084FF';
const jobpalDarkGrey = '#9e9e9e';
const areaMark = {
type: 'area',
color: jobpalBlue,
interpolate: 'monotone',
};
const getDateXObj = rangeLen => ({
field: 'date',
type: `${rangeLen > 30 ? 'temporal' : 'ordinal'}`,
timeUnit: 'yearmonthdate',
axis: {
title: 'Date',
labelAngle: -45,
},
});
const getQuantitativeYObj = (field, title, values) => ({
field,
type: 'quantitative',
axis: {
title,
format: 'd',
values,
},
});
const legendConfig = {
title: null,
offset: -106,
padding: 5,
strokeColor: jobpalDarkGrey,
strokeWidth: 2,
symbolType: 'stroke',
symbolOffset: 0,
symbolStrokeWidth: 10,
labelOffset: 0,
cornerRadius: 10,
symbolSize: 100,
clipHeight: 20,
};
const getSpec = (yAxisValues = [], rangeLen = 0) => ({
$schema: 'https://vega.github.io/schema/vega-lite/v4.json',
title: 'Demo Chart',
layer: [
{
mark: {
...areaMark,
color: jobpalLightGrey,
},
encoding: {
x: getDateXObj(rangeLen),
y: getQuantitativeYObj('user_comments', '', yAxisValues),
stroke: {
field: 'symbol',
type: 'ordinal',
scale: {
domain: ['User Comments', 'Active Users'],
range: [jobpalLightGrey, jobpalBlue],
},
},
},
}, {
mark: areaMark,
encoding: {
x: getDateXObj(rangeLen),
y: getQuantitativeYObj('active_users', '', yAxisValues),
},
},
],
config: {
legend: legendConfig,
},
})
const data = [
{ "user_comments": 0, "active_users": 0, "date": "2019-10-01" },
{ "user_comments": 3, "active_users": 2, "date": "2019-10-02" },
{ "user_comments": 1, "active_users": 0, "date": "2019-10-03" },
{ "user_comments": 1, "active_users": 1, "date": "2019-10-04" },
{ "user_comments": 2, "active_users": 0, "date": "2019-10-05" },
{ "user_comments": 1, "active_users": 0, "date": "2019-10-06" },
{ "user_comments": 2, "active_users": 1, "date": "2019-10-07" }
]
const App = () => {
// get max value from data arary
const yAxisMaxValueFor = (...keys) => {
const maxList = keys.map(key => data.reduce(
// find the item containing the max value
(acc, cur) => (cur[key] > acc[key] ? cur : acc)
)[key]
);
return Math.max(...maxList);
};
const yAxisValues = Array.from(
{ length: yAxisMaxValueFor('active_users', 'user_comments') },
).map((v, i) => (i + 1));
const spec = getSpec(yAxisValues, data.length);
return (
<div className="App">
<Vega
spec={{
...spec,
autosize: 'fit',
resize: true,
contains: 'padding',
width: 400,
height: 300,
data: { values: data },
}}
actions={{
export: true,
source: false,
compiled: false,
editor: false,
}}
downloadFileName={'Just Name It'}
/>
</div>
);
}
export default App;
resize
在實際項目中,我們必須保證圖表大小能跟隨窗口大小變化。接下來,我們來實現這個功能。
圖表在繪製完成後不會重新繪製,但我們可以通過 React 組件接管寬高值來實現重新繪製。
即:
- 在
state
中管理width
和height
- 通過
setState
刷新來實現圖表的重繪 - 在生命週期方法中設置事件監聽函數來監聽
resize
事件 - 結合 css 和
ref
, 通過圖表外的 warper 層得到此時圖表正確的寬高值
示例代碼如下:
import React from 'react';
import { Vega } from 'react-vega';
// chart config
const jobpalBlue = '#e0e0e0';
const jobpalLightGrey = '#0084FF';
const jobpalDarkGrey = '#9e9e9e';
const areaMark = {
type: 'area',
color: jobpalBlue,
interpolate: 'monotone',
};
const getDateXObj = rangeLen => ({
field: 'date',
type: `${rangeLen > 30 ? 'temporal' : 'ordinal'}`,
timeUnit: 'yearmonthdate',
axis: {
title: 'Date',
labelAngle: -45,
},
});
const getQuantitativeYObj = (field, title, values) => ({
field,
type: 'quantitative',
axis: {
title,
format: 'd',
values,
},
});
const legendConfig = {
title: null,
offset: -106,
padding: 5,
strokeColor: jobpalDarkGrey,
strokeWidth: 2,
symbolType: 'stroke',
symbolOffset: 0,
symbolStrokeWidth: 10,
labelOffset: 0,
cornerRadius: 10,
symbolSize: 100,
clipHeight: 20,
};
const getSpec = (yAxisValues = [], rangeLen = 0) => ({
$schema: 'https://vega.github.io/schema/vega-lite/v4.json',
title: 'Demo Chart',
layer: [
{
mark: {
...areaMark,
color: jobpalLightGrey,
},
encoding: {
x: getDateXObj(rangeLen),
y: getQuantitativeYObj('user_comments', '', yAxisValues),
stroke: {
field: 'symbol',
type: 'ordinal',
scale: {
domain: ['User Comments', 'Active Users'],
range: [jobpalLightGrey, jobpalBlue],
},
},
},
}, {
mark: areaMark,
encoding: {
x: getDateXObj(rangeLen),
y: getQuantitativeYObj('active_users', '', yAxisValues),
},
},
],
config: {
legend: legendConfig,
},
})
const data = [
{ "user_comments": 0, "active_users": 0, "date": "2019-10-01" },
{ "user_comments": 3, "active_users": 2, "date": "2019-10-02" },
{ "user_comments": 1, "active_users": 0, "date": "2019-10-03" },
{ "user_comments": 1, "active_users": 1, "date": "2019-10-04" },
{ "user_comments": 2, "active_users": 0, "date": "2019-10-05" },
{ "user_comments": 1, "active_users": 0, "date": "2019-10-06" },
{ "user_comments": 2, "active_users": 1, "date": "2019-10-07" }
];
// get max value from data arary
const yAxisMaxValueFor = (...keys) => {
const maxList = keys.map(key => data.reduce(
// find the item containing the max value
(acc, cur) => (cur[key] > acc[key] ? cur : acc)
)[key]
);
return Math.max(...maxList);
};
const { addEventListener, removeEventListener } = window;
class App extends React.Component {
state = {
width: 400,
height: 300,
}
componentDidMount() {
addEventListener('resize', this.resizeListener, { passive: true, capture: false });
}
componentWillUnmount() {
removeEventListener('resize', this.resizeListener, { passive: true, capture: false });
}
resizeListener = () => {
if (!this.chartWrapper) return;
const child = this.chartWrapper.querySelector('div');
child.style.display = 'none';
const {
clientWidth,
clientHeight: height,
} = this.chartWrapper;
const width = clientWidth - 40; // as padding: "0 20px"
this.setState({ width, height });
child.style.display = 'block';
}
refChartWrapper = el => {
this.chartWrapper = el
if (el) this.resizeListener();
}
yAxisValues = Array.from(
{ length: yAxisMaxValueFor('active_users', 'user_comments') },
).map((v, i) => (i + 1));
render() {
const {width, height, yAxisValues} = this.state;
const spec = getSpec(yAxisValues, data.length);
return (
<div
ref={this.refChartWrapper}
style={{ margin: '10vh 10vw', width: '80vw', height: '50vh' }}
>
<Vega
spec={{
...spec,
autosize: 'fit',
resize: true,
contains: 'padding',
width,
height,
data: { values: data },
}}
actions={{
export: true,
source: false,
compiled: false,
editor: false,
}}
downloadFileName={'Just Name It'}
/>
</div>
);
}
}
export default App;
動圖演示:
至此,圖表已經基本完善。