數據可視化:在 React 項目中使用 Vega 圖表 (二)

效果圖
上一篇講了如何在 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]
            }
        }
      }
  }

user comments

接下來,創建 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 中管理 widthheight
  • 通過 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;

動圖演示:
gif demo

至此,圖表已經基本完善。

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