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

原文做者:IMWeb團隊。未經贊成,禁止轉載。

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

背景

ABCmouse 學校版 爲老師們提供了孩子學習狀況反饋的模塊,其中有一部分數據須要以圖表的方式直觀展現。github

視覺稿

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

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

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

1. 關於 Recharts

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

  1. 聲明式的標籤,讓寫圖表和寫 HTML 同樣簡單
    框架

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

  3. 接口式的 API,解決各類個性化的需求svg

下面是一個輸出的例子,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. 餅圖的實現

自定義的柱狀圖

如圖,這裏的餅圖的圓環部分,使用了 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>複製代碼

獲得圓環:

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

參考 官網例子[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}    />  );}複製代碼

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

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

咱們的目標是在 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;複製代碼

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

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

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

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;複製代碼

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

2.2.3 label 的生成

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

最終的餅圖效果。

3. 條形圖的實現

條形圖

如圖,這裏咱們須要作這樣的一個條形圖,涉及到的元素有兩塊,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>複製代碼

獲得以下效果:

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

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~

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) 指的是柱子左上角的座標。

加上圓角後的效果:

3.3 設置剪切

上面的實現是數據比較均衡的狀況,當數據差別懸殊的狀況下,便暴露出一個讓人心態炸裂的問題,很少說,看下圖。

看左下角= =

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

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

預約義的 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 與 React

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

React 直接渲染 SVG 也進一步打開了個人眼界,原來她不只能夠渲染 HTML 元素,也能夠直接擼 SVG,在實現了適配層的狀況下,咱們還能夠搞 canvas、Native 渲染,甚至嵌入式設備的液晶屏也能夠用[9]。經過 React 實現一套代碼在不一樣的平臺上構造許多複雜的 UI 邏輯,讓我實實在在地感覺到了這樣的抽象的威力所在。

「抽象」與圖表框架的選型

假期看了 SICP 課程[10],它討論了許多關於「抽象」的話題。咱們爲一些複雜的事情創建抽象屏障,避免了咱們的精力被各類重複的雜事給佔據。

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

讓人新把握一門「語言」實際會給人帶來負擔,但通常狀況下咱們察覺不到。當這樣的抽象複雜到了必定程度,這樣的負擔便開始顯現出來。每每咱們的需求並不能被一層抽象知足,而常常去跨越一層層的抽象屏障。

抽象屏障帶來的井井有條

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

想以抽象的方式去歸納複雜的現實,設計上必然會有所側重。這是個矛盾的問題,相似 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/
相關文章
相關標籤/搜索