SVG 菜鳥的 Recharts 自定義圖表實戰

騰訊IMWeb團隊發表於2020-04-01
原文作者:IMWeb團隊。未經同意,禁止轉載。

Recharts 是一款圖表處理的類庫,利用 React 的特性,重新定義了圖表的配置和組合方式,大大地提高了圖表自定義樣式的靈活度。本文記錄了使用 Recharts 結合 SVG 開發自定義樣式圖表的踩坑歷程。

背景

ABCmouse 學校版 為老師們提供了孩子學習情況反饋的模組,其中有一部分資料需要以圖表的方式直觀展示。

SVG 菜鳥的 Recharts 自定義圖表實戰
視覺稿

​這也涉足到了資料視覺化的領域。這個領域細節繁多,靠個人力量難以考慮周全,便需要依賴第三方元件庫。結合這一個需求,在資料視覺化元件庫的選擇上,主要考慮兩點:

  1. 支援 React
  2. 支援靈活自定義樣式

經過一番調研,選擇用 Recharts[1] 實現上述的圖表。

1. 關於 Recharts

Recharts 是一個處理圖表的類庫,re 的含義除了 "React" 外,還代表 "Redifined",重新定義圖表各元素的組合和配置的方式。它基於 React 和 D3 構建,具有以下特點:

  1. 宣告式的標籤,讓寫圖表和寫 HTML 一樣簡單

  2. 貼近原生 SVG 的配置項,讓配置項更加自然

  3. 介面式的 API,解決各種個性化的需求

下面是一個輸出的例子,Recharts 的程式碼也十分地簡潔明瞭,避免了新學習一套配置和 API 帶來的額外負擔。

<BarChart width={520} height={280} data={data}>  <XAxis    dataKey="scene" tickLine={false}    axisLine={{ stroke: "#5dc1fb" }} tick={{ fill: "#999" }}  />  <Bar    dataKey="time" isAnimationActive={!isEmpty}    fill="#8884d8" barSize={32}    shape={<CustomBar />}    label={<CustomLabel />}  >    {data.map((entry, index) => (      <Cell key={index.toString()} />    ))}  </Bar></BarChart>複製程式碼

可以說一個個痛點都被它戳中了,更具體的介紹可以參考作者的介紹文章:元件化視覺化圖表 - Recharts[2]

本文接下來的部分,記錄使用它在實現餅圖條形圖中,遇到的細節問題和實現的過程。

2. 餅圖的實現

SVG 菜鳥的 Recharts 自定義圖表實戰
自定義的柱狀圖

如圖,這裡的餅圖的圓環部分,使用了 PieChart 元件,中間的文字和圖例則直接使用 HTML 渲染,不依賴 Recharts。

這裡簡單地介紹一下 Recharts 實現放大的圓環部分引導線Label 的過程,為你帶來一個對 Recharts 直觀印象。

2.1 實現圓環部分放大

Recharts 提供的 Pie 元件可以實現基本的圓環部分。需要自定義顏色的情況下,通過 Cell 元件把餅圖每一份的顏色傳入。

<PieChart width={480} height={400}>  <Pie data={data} dataKey="value"    cx={200} cy={200}    innerRadius={58} outerRadius={80} paddingAngle={0}    fill="#a08bff" stroke="none"  >    {data.map((entry, index) => (      <Cell key={`cell-${index}`} fill={entry.color} />    ))}  </Pie></PieChart>複製程式碼

得到圓環:

SVG 菜鳥的 Recharts 自定義圖表實戰

接下來需要實現一個滑鼠 Hover 狀態下,放大滑鼠對應的 Sector、再顯示虛線引導線和 label 的效果。

SVG 菜鳥的 Recharts 自定義圖表實戰

參考 官網例子[3],實現 Hover 狀態下放大的 Sector,<Pie /> 提供了一個 ActiveShape 屬性,往裡面傳入一個自定義的 React 元件,重新渲染需要的那一份,然後再傳入一個 activeIndex 指明哪一份需要重新渲染,另外還需要一個 onMouseEnter 函式,更新 activeIndex

<Pie  activeIndex={this.state.activeIndex}  activeShape={renderActiveShape}  data={data} dataKey="value" cx={200} cy={200}  innerRadius={58} outerRadius={80} paddingAngle={0}  fill="#a08bff" stroke="none"  onMouseEnter={this.onPieEnter}>  {data.map((entry, index) => (    <Cell key={`cell-${index}`} fill={entry.color} />  ))}</Pie>複製程式碼

renderActiveShape 的實現,首先返回一個內徑更小,外徑更大的 Sector 。根據 render 函式返回的資訊填充到 Sector 元件上,cx, cy 為 Sector 所在圓環對應圓心的座標。

function renderActiveShape(props) {  const innerOffset = 2; // 內縮  const outerOffset = 4; // 外擴  const {    cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill  } = props;  return (    <Sector      cx={cx} cy={cy}      innerRadius={innerRadius - innerOffset}      outerRadius={outerRadius + outerOffset}      startAngle={startAngle} endAngle={endAngle}      fill={fill}    />  );}複製程式碼

完成圓環部分放大的效果:

SVG 菜鳥的 Recharts 自定義圖表實戰

2.2 實現引導線和標籤

找了一圈 Recharts 的文件沒有發現引導線的元件, 官網例子 的引導線是一段巢狀了 svg 元素的程式碼,作者在做這個需求之前還沒仔細研究過 svg 圖形。怎麼辦呢?學!

開始一波網上衝浪,找到了 MDN 的 SVG 教程[4],過了一遍,有了個基礎印象。在引導線的實現上用了 <path> 元素。

2.2.1 關於 元素

<path> 元素提供一個名為 d 屬性,意思是 "Path Data",包含了路徑的所有資料,資料的格式是一系列的命令,和命令所需要的引數序列。命令與引數之間用空白字元分開。

簡單梳理一下文件中涉及的基本命令和接受的引數:

M x y 畫筆移動到 (x, y),作為起點L x y 畫一條直線到 (x, y)H x 	水平劃線到橫座標 xV y   水平劃線到縱座標 yZ     閉合路徑回到起點(用於建立一個形狀)複製程式碼

它還可以畫貝塞爾曲線和弧形,用到下方的命令:

C x1 y1, x2 y2, x y   三次貝塞爾曲線Q x1 y1, x y          二次貝塞爾曲線A rx ry x-axis-rotation large-arc-flag sweep-flag x y 繪製弧形複製程式碼

關於 d 屬性,本文涉及到的命令都已經列出來了,這裡不再贅述。

<path> 還提供了 strokefill 屬性,分別對應著邊框和填充的顏色,path 本質上是一個閉合路徑形成的形狀,我們畫的圖本質上屬於邊框,因此顏色設定上也是需要用 stroke 來做,具體參考 MDN 關於 Stroke 和 Fill 的介紹[5]

設計同學需要虛線的引導線,SVG 提供了 stroke-dasharray 實現這個需求,它接受一組逗號分隔的數字,這個數字代表著線長和空白的長度的組合。

到這裡,繪製圖形需要的原料基本梳理清楚了。

2.2.2 生成 Path Data

SVG 菜鳥的 Recharts 自定義圖表實戰

我們的目標是在 renderShapeData 裡輸出一個這樣的 Sector + 引導線 + Label,需要通過接收原本只交給 Sector 的輸入,自己生成相應的繪圖資料 d。觀察發現我們需要一個先往外延伸一段,再往水平方向折過去的折線。也就是說我們需要確定一個起點,一箇中間偏折的參考點,還有最後的終點。配合邊框的顏色樣式,我們可以得到如下程式碼。 (這是上述官網的 renderActiveShape 例子的實現思路,我這裡做的也是理解和修改的工作)

<path  d={`M${sx},${sy}      L${mx},${my}      L${ex},${ey}`}  stroke={fill}  strokeDasharray="1,3"  fill="none"/>複製程式碼

確立三個點的座標不難,首先需要確定渲染 activeShape 時的 props 各個屬性在圖形中的含義,這裡用到的有:

const {  cx, cy, innerRadius, outerRadius, startAngle, endAngle, midAngle,  fill, value, name} = props;複製程式碼

涉及到的圓心座標、角度、半徑等引數的含義如圖:

SVG 菜鳥的 Recharts 自定義圖表實戰

這不就是初中學過的「直角三角形」嗎?用三角函式可以很快把三個點的座標分別計算出來。

SVG 菜鳥的 Recharts 自定義圖表實戰

接下來把這一切轉換成程式碼的表達。需要考慮角度弧度轉換、方向等問題。

const RADIAN = Math.PI / 180;const innerOffset = 2; // 內縮const outerOffset = 4; // 外擴const {  cx, cy, midAngle, innerRadius, outerRadius, startAngle, endAngle,  fill, value, name,} = props;const sin = Math.sin(-RADIAN * midAngle);const cos = Math.cos(-RADIAN * midAngle);const sx = cx + (outerRadius - innerOffset) * cos;const sy = cy + (outerRadius + outerOffset) * sin;const mx = cx + (outerRadius + outerOffset + 30) * cos;const my = cy + (outerRadius + outerOffset + 35) * sin;const ex = mx + (cos >= 0 ? 1 : -1) * 80;const ey = my;複製程式碼

這時我們渲染出了想要的引導線:

SVG 菜鳥的 Recharts 自定義圖表實戰

2.2.3 label 的生成

這一步比較簡單,用 SVG 的 <text> 元素處理就好,把上一步引導線用的 (ex, ey) 作為文字的起始座標,再考慮一下 textAnchor 保證對齊方向即可。

最終的餅圖效果。

SVG 菜鳥的 Recharts 自定義圖表實戰

3. 條形圖的實現

SVG 菜鳥的 Recharts 自定義圖表實戰
條形圖

如圖,這裡我們需要做這樣的一個條形圖,涉及到的元素有兩塊,X軸、一系列的柱子,各一個 React 元件。

<BarChart width={520} height={280} data={data}>  <XAxis    dataKey="scene" tickLine={false}    axisLine={{ stroke: "#5dc1fb" }}    tick={{ fill: "#999" }}  />  <Bar dataKey="time" fill="#8884d8" barSize={32} /></BarChart>複製程式碼

得到如下效果:

SVG 菜鳥的 Recharts 自定義圖表實戰

到了這一步,我們距離最終目標還差條形圖的標籤,漸變和圓角的頂部。

3.1 漸變的實現

首先我們解決漸變的問題,查詢MDN 關於漸變的文件[6],發現實現其實很簡單,只需要往 <defs> 元素插入一個 <linearGradient> 節點,然後再在需要應用漸變的元素的 fill 屬性(填充)設為 url(#漸變節點的id屬性值) 即可。

Recharts 文件沒有說到 <defs> 元素,看 SVG 裡面所有漸變、CSS 等定義都集中在了檔案開頭的 <defs> 裡面。腦洞:我直接在元件裡面寫 <defs> 是否能出現在最終生成的 <svg> 裡面呢?試著寫了下,還真可以!說明這個腦洞是可行的。

看,加入漸變後的 JSX 程式碼,還是那麼簡潔:

<BarChart  width={520}  height={280}  data={data}>  <defs>    <linearGradient x1="0" x2="0" y1="0" y2="1">      <stop offset="0%" stopColor="#00ddee" />      <stop offset="100%" stopColor="#5dc1fb" />    </linearGradient>  </defs>  <XAxis    dataKey="scene"    tickLine={false}    axisLine={{ stroke: "#5dc1fb" }}    tick={{ fill: "#999" }}  />  ...</BarChart>複製程式碼

So easy~

SVG 菜鳥的 Recharts 自定義圖表實戰

3.2 頂部改為圓角

接下來我們實現圓角的頂部,它本質上是一個封閉的 <path>,我們只需要畫一個頂部為圓角的矩形就可以了。

這裡我們用到 <Bar> 元件提供的 shape 屬性,傳入一個自定義元件 <CustomBar> 處理。

<Bar  dataKey="time"  fill="url(#abc-bar-gradient)"  barSize={32}  shape={<CustomBar />}/>複製程式碼

接下來我們的關注點和精力都放在如何實現這個 <CustomBar /> 上,填充 fill 就用上級繼承過來的,核心的問題在於如何計算這個 d

實現程式碼如下,搞清楚 x, y, width, height 的含義以後,一切都變得十分簡單。

function CustomBar(props) {  const { fill, x, y, width, height } = props;  const radius = width / 2;  const d = `M${x},${y + height}    L${x},${y + radius}    A${radius},${radius} 0 0 1 ${x + width},${y + radius}    L${x + width},${y + height}    Z`;  return (    <path d={d} stroke="none" fill={fill} />  );}複製程式碼

(x, y) 指的是柱子左上角的座標。

SVG 菜鳥的 Recharts 自定義圖表實戰

加上圓角後的效果:

SVG 菜鳥的 Recharts 自定義圖表實戰

3.3 設定剪下

上面的實現是資料比較均衡的情況,當資料差異懸殊的情況下,便暴露出一個讓人心態炸裂的問題,不多說,看下圖。

SVG 菜鳥的 Recharts 自定義圖表實戰
看左下角= =

我們想實現一個圓角矩形,但 (x, y) 實際上是位於半圓的左邊空白部分的左上角。當這個點太接近座標軸,加上圓角半徑以後,圓角的起點的縱座標便超出範圍,導致了這種詭異的情況。能不能把它隱藏起來呢?

怎麼能不可以!繼續網上衝浪,找到 SVG 的剪下功能[7],恰好 recharts 生成的 SVG 也有 <clipPath> 元素的存在,想必作者有考慮過這一點。

SVG 菜鳥的 Recharts 自定義圖表實戰
預定義的 clipPath

也就是說,我直接在柱子裡面引用這裡帶的 clipPath 就好了,但它的字首帶著一個彷彿是個 id,這個 id 看起來似乎是全域性統一自增的。怎麼獲取到確切的 id 呢?

深入 recharts 原始碼,找到了這裡提到的 clipPath 的 id 的定義[8],原來我們需要在最外層的 <BarChart /> 傳入一個固定的 id 屬性。

<BarChart  width={520}  height={280}  data={data}  id={uniqueId}>  ...</BarChart>複製程式碼

<CustomBar /> 裡面渲染的 <path> 傳入一個帶著一個我們可控的 id 組合之後得到的 clipPath,問題解決。

function CustomBar(props) {  const { fill, x, y, width, height } = props;  const radius = width / 2;  const d = `M${x},${y + height}    L${x},${y + radius}    A${radius},${radius} 0 0 1 ${x + width},${y + radius}    L${x + width},${y + height}    Z`;  return (    <path d={d} stroke="none" fill={fill}      clipPath={`url(#${uniqueId}-clip)`}    />  );}複製程式碼

3.4 Label 的實現

同樣的思路,我們直接在 <Bar> 元件提供的 label 屬性定義一個 <CustomLabel /> 元件。

<Bar  isAnimationActive={!isEmpty}  dataKey="time"  fill="url(#abc-bar-gradient)"  barSize={32}  shape={<CustomBar />}  label={<CustomLabel />}/>複製程式碼

程式碼與修改思路也類似,有問題用 DevTools 跟蹤一波,再給文字自定義格式化一下(這裡抽象成了 getStudyTime 函式)。

function CustomLabel(props) {  const { x, y, width, height, value } = props;  return (    <text      x={x + width / 2 - 1} y={y - 10}      width={width} height={height}      fill="#999"      className="recharts-text recharts-label"      textAnchor="middle"    >      {getStudyTime(value)}    </text>  );};複製程式碼

3.5 最終效果

SVG 菜鳥的 Recharts 自定義圖表實戰
條形圖

總結與感想

關於 SVG 與 React

在做這個需求時也開始直接入門了 SVG,掌握了新的一門控制視覺展示的技術,滿滿的收穫~

React 直接渲染 SVG 也進一步開啟了我的眼界,原來她不僅可以渲染 HTML 元素,也可以直接擼 SVG,在實現了適配層的情況下,我們還可以搞 canvas、Native 渲染,甚至嵌入式裝置的液晶屏也可以用[9]。通過 React 實現一套程式碼在不同的平臺上構造許多複雜的 UI 邏輯,讓我實實在在地感受到了這樣的抽象的威力所在。

“抽象”與圖表框架的選型

假期看了 SICP 課程[10],它討論了許多關於“抽象”的話題。我們為一些複雜的事情建立抽象屏障,避免了我們的精力被各種重複的瑣事給佔據。

抽象的目的在於隱藏背後的複雜,創造抽象屏障的本質上也同時創造出一種新的溝通方式,某種意義上可以說是一種“語言”。

讓人新把握一門“語言”實際會給人帶來負擔,但一般情況下我們察覺不到。當這樣的抽象複雜到了一定程度,這樣的負擔便開始顯現出來。往往我們的需求並不能被一層抽象滿足,而經常去跨越一層層的抽象屏障。

SVG 菜鳥的 Recharts 自定義圖表實戰
抽象屏障帶來的層次分明

跨越多層抽象屏障,也就意味著需要同時把握更多的“語言”以及它們之間的千絲萬縷關係,導致複雜度大大增加,無形中就帶來了許多的坑。

想以抽象的方式去概括複雜的現實,設計上必然會有所側重。這是個矛盾的問題,類似 ECharts 這樣側重於簡單配置的圖表視覺化元件,如果嘗試去做精細的定製改造,難度將會非常大;Recharts 更側重於定製化,它為我們提供了能直接觸及到最終 UI 展現的方式,藉助於 React,定製的過程也足夠簡單。我們做元件庫選型的時候,得考慮目標在不同維度之下的比較和權衡,根據需求在其中的側重之處,做最合適的選擇。

參考資料

[1] Recharts: http://recharts.org/
[2] 元件化視覺化圖表 - Recharts: https://zhuanlan.zhihu.com/p/20641029
[3] 官網自定義 ActiveShape 例子: http://recharts.org/en-US/examples/CustomActiveShapePieChart
[4] SVG 教程: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial
[5] MDN 關於 Stroke 和 Fill 的介紹: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Fills_and_Strokes
[6] MDN 關於漸變的文件: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Gradients
[7] SVG 的剪下功能: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Clipping_and_masking
[8] clipPath 的 id 的定義: https://github.com/recharts/recharts/blob/master/src/chart/generateCategoricalChart.tsx#L172
[9] 將 React 渲染到嵌入式液晶屏: https://juejin.im/post/5dbb729e51882524c101ffe1
[10] Bilibili Learning-SICP 課程: https://www.bilibili.com/video/av8515129/


相關文章