Airbnb 一向在前端與設計上有深刻著墨,總能推出質感很好的工具給相關人員使用,而在上個月他們釋出了 v1.0 版本的資料視覺化套件 - visx,強調特色是架構在 React 上,提供與類似 D3 的底層 API 來製作圖表。然而結合 React 與 D3 的套件何其多,Airbnb 出品的 visx 與其它產品的差異是什麼,使用起來的感覺又是如何,今天趁著雙十連假,嘗試實際寫寫看,並跟大家分享。
照例先展示個最終範例:
visx 在三年前就已經開源了,當時叫做 vx,一直處於 beta 的狀態,而實際上在 Airbnb 內部已經應用在各種正式環境的專案上兩年多,中間經過許多更新並以 TypeScript 重寫過,才在上個月以 1.0 的版本再次問世。
如同前言提到的,市面上不乏整合 React 與 D3 的套件可以使用,而且大多數都盡量設計得簡單易用,資料傳進去,一組 Bar Chart 就出來了,為什麼 Airbnb 要在自己打造一套工具呢?
在官方的 blog 中他們繪製了一張圖精簡的解釋了這個工具存在的意義:
(偷偷吐槽一下,他們的 logo 放在這圖裡感覺像是在那欄畫了一的大叉叉...)
那些隨插即用的 Chart library 之所以無法撼動 D3 地位的原因在於缺乏足夠的 Expressive,也就是不夠底層,能夠操控的範圍受限,相對的,D3 則提供了非常多的底層介面讓你能細緻的操作資料與畫面的整合互動。
然而 D3 對於前端工程師來說,最讓人畏懼的就是其陡峭的學習曲線,尤其當你嘗試將 D3 直接運用在你的 React 專案內時,兩種截然不同的 mental model 與操作 DOM 的方式,相信一定會在你心裡留有芥蒂,當然也容易產生 Bug。
因此 visx 針對這幾個問題做了解決,提供以下主要特色:
以 Typescript 與 React 實作
以模組化的方式提供豐富的底層「畫面操作」元件和少許的 Layout, interaction, svg, data utilities
import { Bar } from '@vx/shape';
Un-opinionated on purpose
與 D3 能互相搭配使用
綜合以上特色,使用 visx 與使用 D3 的最大差異在於,你不再需要了解 d3.select
, data join, enter/exit status 等等屬於 D3 根據 data 更新 DOM 的邏輯思路,但又能保有相對 primitives 的元件可使用,而且在進行一些單純的資料運算、scale 函式上,你也還是能使用 D3 提供的 utils。 其餘的一切都是 React,包含 Layout responsive 等等都是由 React component 來負責。
簡單介紹完 visx 的特色,接著就來實際把玩看看!官方 github 附有一個簡單的 bar chart 圖表,但每次都使用 bar chart 做範例太無趣,因此挑個稍微複雜一點的 Radar chart(雷達圖) 來做範例。
雷達圖適合用來呈現多維度的資料,用來展示臺灣六都的幾個重要空氣品質指標項目感覺蠻適合的,加上其 API 在 政府 open data 中又算是比較方便取用的一個...
API 取自 行政院環境保護署。環境資源資料開放平臺,每小時會更新一次。
為了體驗 visx 的特色,這次範例的製作流程大致如下:
完整程式碼可以從 CodeSandbox 上取得,這邊只會擷取重要部分。
那麼就開始吧!
老實說 visx 官網其實沒什麼文件,都是很基本的 props 類型與介紹,範例就直接丟程式碼給你,唯一一篇手把手教你建立出一個 bar chart 的教學也是三年前寫的。
在這條件下,最好的學習方式也只有從範例開始,剛好在 visx 官網的 Gallery 已經有個雷達圖的範例,可以根據範例進行修改。
首先,資料視覺化的第一步就是準備好資料:
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
, ScaleSVG
和 withScreenSize
。從名稱很簡單可以看出功能,這邊我採用 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 本身關係不大,這邊不詳述細節,可以到
查看實際程式碼。
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
, Line
與 Text
基本對應 svg 內的 g
, line
和 text
;LineRadial
則是 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
等資料更新狀態。
到這邊為止就能繪製出這樣的圖表:
在上一步中,只有繪製一份資料的圖表,現在是時候把六個直轄市的資料都放進來,並且加上 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>
此步驟成果如下:
基本功能都完成後,就得來加上點動畫,順便體驗看看 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.polygon
,react-spring
就會幫我們進行其中的補間動畫。完全不需要思考什麼 data enter, data exit,就是單純的 react component animation:
最後再加上一點顏色變化,也試試看 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,若是看到這邊的讀者有興趣,歡迎去玩玩看,翻翻他們的程式碼,說不定也有你能貢獻的地方!