Well in Time
使用 visx 製作資料圖表-台灣六都即時空氣品質指標
10-10-202014 Min Read

好歌分享:Sunset Rollercoaster - Candlelight feat. OHHYUK

前言

Airbnb 一向在前端與設計上有深刻著墨,總能推出質感很好的工具給相關人員使用,而在上個月他們釋出了 v1.0 版本的資料視覺化套件 - visx,強調特色是架構在 React 上,提供與類似 D3 的底層 API 來製作圖表。然而結合 React 與 D3 的套件何其多,Airbnb 出品的 visx 與其它產品的差異是什麼,使用起來的感覺又是如何,今天趁著雙十連假,嘗試實際寫寫看,並跟大家分享。

照例先展示個最終範例:

final demo

Edit SimpleRadar-AQI (with Tooltip + select data)  (react-spring)

visx 簡介

visx 在三年前就已經開源了,當時叫做 vx,一直處於 beta 的狀態,而實際上在 Airbnb 內部已經應用在各種正式環境的專案上兩年多,中間經過許多更新並以 TypeScript 重寫過,才在上個月以 1.0 的版本再次問世。

如同前言提到的,市面上不乏整合 React 與 D3 的套件可以使用,而且大多數都盡量設計得簡單易用,資料傳進去,一組 Bar Chart 就出來了,為什麼 Airbnb 要在自己打造一套工具呢?

在官方的 blog 中他們繪製了一張圖精簡的解釋了這個工具存在的意義:

why visx (偷偷吐槽一下,他們的 logo 放在這圖裡感覺像是在那欄畫了一的大叉叉...)

那些隨插即用的 Chart library 之所以無法撼動 D3 地位的原因在於缺乏足夠的 Expressive,也就是不夠底層,能夠操控的範圍受限,相對的,D3 則提供了非常多的底層介面讓你能細緻的操作資料與畫面的整合互動。

然而 D3 對於前端工程師來說,最讓人畏懼的就是其陡峭的學習曲線,尤其當你嘗試將 D3 直接運用在你的 React 專案內時,兩種截然不同的 mental model 與操作 DOM 的方式,相信一定會在你心裡留有芥蒂,當然也容易產生 Bug。

因此 visx 針對這幾個問題做了解決,提供以下主要特色:

  1. 以 Typescript 與 React 實作

    • 這個 tech stack 組合應該是目前前端主流之一,Airbnb 的官方前端語言就是 Typescript,網站也是以 React 為主,以此組合實作能降低前端工程師踏入資訊視覺化專案的門檻,熟悉的感覺最對味。
  2. 以模組化的方式提供豐富的底層「畫面操作」元件和少許的 Layout, interaction, svg, data utilities

    • 前端在乎的不外乎美觀、效率與安全,在操作資訊視覺專案時,面對大量資料,降低 Bundle size 以增加效能就很重要了,比起其他 Chart library,visx 提供各種底層元件讓你自己選擇組裝,要使用的在載入即可,不需要整包裝進去你的 Bundle 內。e.g. import { Bar } from '@vx/shape';
  3. Un-opinionated on purpose

    • visx 很大的一個特點是,所提供的模組與元件都是以 React 為基礎實作的,因此無論是狀態管理、動畫操作、CSS solution 等等,你都不需要特別為了 visx 去做處理或是額外的整合,就當作一般寫 React project 即可,你原本如何做動畫、如何 theming, styling,都可以沿用。
  4. 與 D3 能互相搭配使用

    • visx 主要提供底層操作 DOM 的元件,讓前端工程師能用一套 mental modal 處理畫面,而關於 data operation 或 scale function 等運算,visx 設計上就是希望能繼續利用 D3 所提供的強大函式庫來處理。

綜合以上特色,使用 visx 與使用 D3 的最大差異在於,你不再需要了解 d3.select, data join, enter/exit status 等等屬於 D3 根據 data 更新 DOM 的邏輯思路,但又能保有相對 primitives 的元件可使用,而且在進行一些單純的資料運算、scale 函式上,你也還是能使用 D3 提供的 utils。 其餘的一切都是 React,包含 Layout responsive 等等都是由 React component 來負責。

visx doc

實際演練 - 台灣六都即時空氣品質指標

簡單介紹完 visx 的特色,接著就來實際把玩看看!官方 github 附有一個簡單的 bar chart 圖表,但每次都使用 bar chart 做範例太無趣,因此挑個稍微複雜一點的 Radar chart(雷達圖) 來做範例。

雷達圖適合用來呈現多維度的資料,用來展示臺灣六都的幾個重要空氣品質指標項目感覺蠻適合的,加上其 API 在 政府 open data 中又算是比較方便取用的一個...

API 取自 行政院環境保護署。環境資源資料開放平臺,每小時會更新一次。

為了體驗 visx 的特色,這次範例的製作流程大致如下:

  1. 根據 API 資料,利用 visx 元件製作出基本畫面(了解基本使用方法)
  2. 增加資料切換與 tooltip 使用(加一點變化)
  3. 採用 react-spring 增加切換資料時的動畫(確認是否 Un-opinionated on animation)
  4. 採用 d3-scale 來處理顏色變換(了解如何一起運用 D3)

完整程式碼可以從 CodeSandbox 上取得,這邊只會擷取重要部分。

那麼就開始吧!

第一步,使用 visx 元件堆疊出基本畫面

老實說 visx 官網其實沒什麼文件,都是很基本的 props 類型與介紹,範例就直接丟程式碼給你,唯一一篇手把手教你建立出一個 bar chart 的教學也是三年前寫的。

在這條件下,最好的學習方式也只有從範例開始,剛好在 visx 官網的 Gallery 已經有個雷達圖的範例,可以根據範例進行修改。

本步驟的結果可以從這個 CodeSandbox 取得: Edit SimpleRadar-AQI

首先,資料視覺化的第一步就是準備好資料:

const useDataFetch = () => {
  const [data, setData] = useState([]);
  const URI =
    "https://opendata.epa.gov.tw/api/v1/AQI?%24skip=0&%24top=1000&%24format=json";
  useEffect(() => {
    const fetchData = async () => {
      try {
        const data = fallbackData; // await (await fetch(URI)).json();
        const fileterdData = data
          .filter(/* data process */)
          .map(/* data process */);
        setData(fileterdData);
      } catch (error) {
        console.log({ error });
      }
    };
    fetchData();
  }, []);
  return [data];
};

export default useDataFetch;

利用先前提到的保護署開放資料 API 來取得即時的六都空氣品質資訊。不過 API response time 很久,一次 request 可能要等個十幾二十秒...所以開發上我多放個 fallback data 擋著,才部會一直沒有畫面出現。

題外話,visx 也提供一些 mockdata 供你使用,像是 apple stock import { appleStock } from '@vx/mock-data';

接著我們需要定義一下整個圖表的大小,也就是長寬,你可以定義固定大小的圖表,或是使用 visx 中一個叫做 @visx/responsive 的 package,裡面有三種控制元素大小的 HOC 可以使用:ParentSize, ScaleSVGwithScreenSize。從名稱很簡單可以看出功能,這邊我採用 ParentSize

render(
  <ParentSize>
    {({ width, height }) => <Radar width={width} height={height} />}
  </ParentSize>,
  document.getElementById("root")
);

index.tsx 中,直接用 ParentSize 包住我的 Radar component,將其長寬傳入,如此一來,只要 Parent container size 改變了,我的 Radar component 就會根據新傳入的長寬去進行調整。

有了 Container 的大小後,接著定義雷達圖的半徑、大小:

const xMax = width - margin.left - margin.right;
const yMax = height - margin.top - margin.bottom;
const radius = Math.min(xMax, yMax) / 2;

const radialScale = scaleLinear<number>({
  range: [0, Math.PI * 2],
  domain: [degrees, 0]
});

const yScale = scaleLinear<number>({
  range: [0, radius],
  domain: [0, Math.max(...data.map(y))]
});

const webs = genAngles(data.length);
const points = genPoints(data.length, radius);
const polygonPoints = genPolygonPoints(data, (d) => yScale(d) ?? 0, y);

這邊基本上只要用過 D3 的人都會覺得熟悉,在真正渲染圖表前,會需要依照資料內容、長度等等製作適當的 scale 函式,來映射適當的元素大小到圖表上。

雷達圖需要知道的不外乎是資料所在的角度(radialScale)與半徑位置(yScale),visx 的 @visx/scale 提供幾種 scale 函式供你使用,基本上他就是在 d3-scale 上外加一層 wrapper。

而雷達本身的點與線段(genAngles, genPoints, genPolygonPoints),基本上都是基本的數學運算,跟 visx 本身關係不大,這邊不詳述細節,可以到 Edit SimpleRadar-AQI 查看實際程式碼。

scale 函式也準備就緒後,就可以來創建雷達圖表本身了:

const Radar = (<svg width={width} height={height}> </svg>);

visx 的元件基本上都預期你將其 render 在 svg 元素內。

接著載入幾個建立雷達圖需要的 packages:

import { Group } from "@visx/group";
import { Line, LineRadial } from "@visx/shape";
import { Text } from "@visx/text";

Group, LineText 基本對應 svg 內的 g, linetextLineRadial 則是 visx 提供的 shape 元件

組合起來的 render function:

<svg width={width} height={height}>
  <rect fill={background} width={width} height={height} rx={14} />
  <Group top={height / 2 - margin.top} left={width / 2}>
    {[...new Array(levels)].map((_, i) => (
      <LineRadial
        key={`web-${i}`}
        data={webs}
        angle={(d) => radialScale(d.angle) ?? 0}
        radius={((i + 1) * radius) / levels}
        fill="none"
        stroke={silver}
        strokeWidth={2}
        strokeOpacity={0.8}
        strokeLinecap="round"
      />
    ))}
    {[...new Array(data.length)].map((_, i) => (
      <>
        <Line
          key={`radar-line-${i}`}
          from={zeroPoint}
          to={points[i]}
          stroke={silver}
        />
        <Text
          textAnchor="middle"
          verticalAnchor="middle"
          dx={points[i].x}
          dy={points[i].y}
        >
          {data[i].key}
        </Text>
      </>
    ))}
    <polygon
      points={polygonPoints.pointString}
      fill={orange}
      fillOpacity={0.3}
      stroke={orange}
      strokeWidth={1}
    />
    {polygonPoints.points.map((point, i) => (
      <circle
        key={`radar-point-${i}`}
        cx={point.x}
        cy={point.y}
        r={4}
        fill={pumpkin}
      />
    ))}
  </Group>
</svg>

到這邊可以發現,你就是在寫 React 而已,把 visx 提供的 component 堆疊到 svg 元素中,然後把 data 當作 props 傳入即可,不用再去思考什麼 d3.select,更不用理解 D3 中 enter/exit 等資料更新狀態。

到這邊為止就能繪製出這樣的圖表:

simple-AQI

增加資料切換與 tooltip 使用

在上一步中,只有繪製一份資料的圖表,現在是時候把六個直轄市的資料都放進來,並且加上 tooltip 來呈現詳細資訊,這樣才是一個合格的資訊圖表。

const [apiData] = useDataFetch();
const [selectedIdx, setSelectedIdx] = useState(0);
const data = apiData[selectedIdx]?.info || [];

更新資料的部分,直接用 useState 去更改要傳入給 component 的 props 即可。

<Selector setSelectedIdx={setSelectedIdx} apiData={apiData} />

可以與其他 react component 結合,這邊我額外實作一個 selector component 來切換六都資料。

當資料切換,setSelectedIdx 被呼叫,component 重新 render,所有 svg 內我們堆疊的 component 也都會進行更新,就是 React 的邏輯。

而 tooltip 的部分,可以利用 @visx/tooltip 來完成,@visx/tooltip 跟目前主流的 react 套件一樣,提供 hook 與 HOC 兩種方法可供使用:

Hooks: useTooltip(), HOC: withTooltip()

這次的範例我採用 Hooks:

const {
    tooltipData,
    tooltipLeft,
    tooltipTop,
    tooltipOpen,
    showTooltip,
    hideTooltip
  } = useTooltip();
  const handleMouseOver = useCallback(
    (coords, datum) => {
      showTooltip({
        tooltipLeft: coords.x,
        tooltipTop: coords.y,
        tooltipData: datum
      });
    },
    [showTooltip]
  );

useTooltip() 回傳 tooltip 內的資料、位置、現在開啟與否,以及控制顯示與隱藏 tooltip 的函式。搭配一些 event handler 就能輕鬆達成 tooltip 功能。

至於 tooltip 本身的元件,並不是由 Hooks 回傳,得從 @visx/tooltip 中載入 Tooltip。此外,這個 Tooltip 元件其實蠻雷的,他跟其他的 visx component 不同,不是讓你繪製在 svg 內,而是 render 出一個 div,要小心不要跟其他 component 一起放到 svg 內了。

另外,Tooltip 使用 position: absolute 來控制位置,這代表著你必須需要提供他一個 Wrapper 是 position: relatieve,才能正確地顯示相對位置,這並不是這麼好調整,算是 visx 我使用起來覺得有待改善的部分。

<div style={{ position: 'relative' }}>
  <svg>
  {/* other components */}
  </svg>
  {tooltipOpen && (
    <Tooltip
      key={Math.random()}
      top={tooltipTop + height / 2}
      left={tooltipLeft + width / 2}
      style={tooltipStyles}
    >
      <strong>{tooltipData}</strong>
    </Tooltip>
  )}
</div>

此步驟成果如下:

selector-tooltip Edit SimpleRadar-AQI (with Tooltip + select data)

使用 react-spring 讓畫面動起來

基本功能都完成後,就得來加上點動畫,順便體驗看看 visx 所謂的 un-opinionated on purpose 是什麼感覺。

如果是用 D3 繪製圖表,當你要製作動畫時,必須得注意資料的 join 狀態,在沒有了解 enter/exit 的概念前,要讓 d3 圖表動起來,會感覺是盲人摸象看不清。

而使用 visx 的話,基本上你要操控的就是將資料當作 props 傳入的 component,要套上哪一套 react animation library 都可以,我採用 react-spring 來將 polygon 在資料切換時做位移的 transform:

import { useSpring, animated } from "react-spring";

react-spring 的用法也算簡單,提供一個 useSpring Hooks 來產生一個 spring(moves data from a -> b):

const polygonPoints = genPolygonPoints(data, (d) => yScale(d) ?? 0, y);
const polygonProps = useSpring({ points: polygonPoints.pointString });

我們想要讓 polygon points 變動時有動畫效果,方法就是透過 useSpring 針對 polygon points 生成一個 spring,然後將該 spring 傳入以 animated.polygon 取代的 polygon 中:

+<animated.polygon
+ points={polygonProps.points}
-<polygon
- points={polygonPoints.pointString}
  fill={polygonColor}
  fillOpacity={0.3}
  stroke={polygonColor}
  strokeWidth={1}
/>

每當資料切換,component 重新 render 時,新產生的 spring 就會被傳給 animated.polygonreact-spring 就會幫我們進行其中的補間動畫。完全不需要思考什麼 data enter, data exit,就是單純的 react component animation:

final demo

搭配 d3-scale 進行資料與顏色運算

最後再加上一點顏色變化,也試試看 D3 與 visx 的搭配。

以 d3-scale 來負責顏色的運算,讓 visx 負責圖表元件的渲染:

import { scaleSequential } from "d3-scale";
import { max } from "d3-array";
import { interpolateOrRd } from "d3-scale-chromatic";

使用 scaleSequential 搭配 interpolateOrRd 來對應不同 AQI 的數值顏色。

const AQIvalue = apiData.reduce((acc, prev) => {
  acc.push(prev.info[0].value);
  return acc;
}, []);
const colorScale = scaleSequential(interpolateOrRd).domain([
  0,
  max(AQIvalue)
]);
const polygonColor = colorScale(data[0].value);

從上面的程式碼可以看出,這邊我們單純的運用資料與 d3 packages 進行運算,產出的顏色直接當作 props 傳入 visx 元件即可完成我們想要的效果:

<animated.polygon
  points={polygonProps.points}
  fill={polygonColor}
  fillOpacity={0.3}
  stroke={polygonColor}
  strokeWidth={1}
/>

如此一來就大功告成啦!完整版程式碼請參考:

結論

visx 的使用體驗蠻好的,提供底層的元件讓你自己組裝,搭配上 d3 方便的資料處理套件,基本上可以用來建造屬於你們自己 team 內的 chart library。

雖然已經開發三年,但感覺得出來還是有不少功能需要加入或改善,他們的 maintainer 目前還算蠻積極在回應 issue,若是看到這邊的讀者有興趣,歡迎去玩玩看,翻翻他們的程式碼,說不定也有你能貢獻的地方!

資料來源

  1. visx
  2. Introducing visx from Airbnb
  3. ECharts 數據可視化實驗室
© by Arvin Huang. All rights reserved.
theme inspired by @mhadaily
Last build: 05-05-2023