天天看點

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

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

背景

ABCmouse 學校版 為老師們提供了孩子學習情況回報的子產品,其中有一部分資料需要以圖表的方式直覺展示。

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

視覺稿

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

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

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

1. 關于 Recharts

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

  1. 聲明式的标簽,讓寫圖表和寫 HTML 一樣簡單
  2. 貼近原生 SVG 的配置項,讓配置項更加自然
  3. 接口式的 API,解決各種個性化的需求

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

520} height={280} data={data}><XAxisdataKey="scene" tickLine={false}axisLine={{ stroke: "#5dc1fb" }} tick={{ fill: "#999" }}
  /><BardataKey="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 path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

自定義的柱狀圖

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

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

2.1 實作圓環部分放大

Recharts 提供的 

Pie

 元件可以實作基本的圓環部分。需要自定義顔色的情況下,通過 

Cell

 元件把餅圖每一份的顔色傳入。

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 path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

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

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

參考 官網例子[3],實作 Hover 狀态下放大的 Sector,

 提供了一個 

ActiveShape

 屬性,往裡面傳入一個自定義的 React 元件,重新渲染需要的那一份,然後再傳入一個 

activeIndex

 指明哪一份需要重新渲染,另外還需要一個 

onMouseEnter

 函數,更新 

activeIndex

 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 (
    <Sectorcx={cx} cy={cy}innerRadius={innerRadius - innerOffset}outerRadius={outerRadius + outerOffset}startAngle={startAngle} endAngle={endAngle}fill={fill}
    />
  );
}           

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

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

2.2 實作引導線和标簽

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

開始一波網上沖浪,找到了 MDN 的 SVG 教程[4],過了一遍,有了個基礎印象。在引導線的實作上用了 

 元素。

2.2.1 關于 元素

 元素提供一個名為 

d

 屬性,意思是 "Path Data",包含了路徑的所有資料,資料的格式是一系列的指令,和指令所需要的參數序列。指令與參數之間用空白字元分開。

簡單梳理一下文檔中涉及的基本指令和接受的參數:

M x y 畫筆移動到 (x, y),作為起點
L x y 畫一條直線到 (x, y)
H x 	水準劃線到橫坐标 x
V y   水準劃線到縱坐标 y
Z     閉合路徑回到起點(用于建立一個形狀)
           

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

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

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

 還提供了 

stroke

 和 

fill

 屬性,分别對應着邊框和填充的顔色,path 本質上是一個閉合路徑形成的形狀,我們畫的圖本質上屬于邊框,是以顔色設定上也是需要用 

stroke

 來做,具體參考 MDN 關于 Stroke 和 Fill 的介紹[5]。

設計同學需要虛線的引導線,SVG 提供了 

stroke-dasharray

 實作這個需求,它接受一組逗号分隔的數字,這個數字代表着線長和空白的長度的組合。

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

2.2.2 生成 Path Data

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

我們的目标是在 

renderShapeData

 裡輸出一個這樣的 

Sector

 + 

引導線

 + 

Label

,需要通過接收原本隻交給 Sector 的輸入,自己生成相應的繪圖資料 

d

。觀察發現我們需要一個先往外延伸一段,再往水準方向折過去的折線。也就是說我們需要确定一個起點,一個中間偏折的參考點,還有最後的終點。配合邊框的顔色樣式,我們可以得到如下代碼。 (這是上述官網的 

renderActiveShape

 例子的實作思路,我這裡做的也是了解和修改的工作)

 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 path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

這不就是國中學過的「直角三角形」嗎?用三角函數可以很快把三個點的坐标分别計算出來。

svg path繪制心形_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 path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

2.2.3 label 的生成

這一步比較簡單,用 SVG 的 

 元素處理就好,把上一步引導線用的 (ex, ey) 作為文字的起始坐标,再考慮一下 

textAnchor

 保證對齊方向即可。

最終的餅圖效果。

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

3. 條形圖的實作

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

條形圖

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

520} height={280} data={data}><XAxisdataKey="scene" tickLine={false}axisLine={{ stroke: "#5dc1fb" }}tick={{ fill: "#999" }}
  /><Bar dataKey="time" fill="#8884d8" barSize={32} />BarChart>           

得到如下效果:

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

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

3.1 漸變的實作

首先我們解決漸變的問題,查找MDN 關于漸變的文檔[6],發現實作其實很簡單,隻需要往 

 元素插入一個 

 節點,然後再在需要應用漸變的元素的 

fill

 屬性(填充)設為 

url(#漸變節點的id屬性值)

 即可。

Recharts 文檔沒有說到 

 元素,看 SVG 裡面所有漸變、CSS 等定義都集中在了檔案開頭的 

 裡面。腦洞:我直接在元件裡面寫 

 是否能出現在最終生成的 

 裡面呢?試着寫了下,還真可以!說明這個腦洞是可行的。

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

 width={520}
  height={280}
  data={data}
>
  
      dataKey="scene"
    tickLine={false}
    axisLine={{ stroke: "#5dc1fb" }}
    tick={{ fill: "#999" }}
  />
  ...

           

So easy~

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

3.2 頂部改為圓角

接下來我們實作圓角的頂部,它本質上是一個封閉的 

,我們隻需要畫一個頂部為圓角的矩形就可以了。

這裡我們用到 

 元件提供的 

shape

 屬性,傳入一個自定義元件 

 處理。

 dataKey="time"
  fill="url(#abc-bar-gradient)"
  barSize={32}
  shape={}
/>
           

接下來我們的關注點和精力都放在如何實作這個 

 上,填充 

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 path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

加上圓角後的效果:

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

3.3 設定剪切

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

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

看左下角= =

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

怎麼能不可以!繼續網上沖浪,找到 SVG 的剪切功能[7],恰好 recharts 生成的 SVG 也有 

 元素的存在,想必作者有考慮過這一點。

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

預定義的 clipPath

也就是說,我直接在柱子裡面引用這裡帶的 clipPath 就好了,但它的字首帶着一個仿佛是個 id,這個 id 看起來似乎是全局統一自增的。怎麼擷取到确切的 id 呢?

深入 recharts 源碼,找到了這裡提到的 clipPath 的 id 的定義[8],原來我們需要在最外層的 

 傳入一個固定的 

id

 屬性。

 width={520}
  height={280}
  data={data}
  id={uniqueId}
>
  ...
</BarChart>           

往 

 裡面渲染的 

 傳入一個帶着一個我們可控的 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 的實作

同樣的思路,我們直接在 

 元件提供的 

label

 屬性定義一個 

 元件。

 isAnimationActive={!isEmpty}
  dataKey="time"
  fill="url(#abc-bar-gradient)"
  barSize={32}
  shape={}
  label={}
/>
           

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

getStudyTime

 函數)。

function CustomLabel(props) {
  const { x, y, width, height, value } = props;
  return (
    <textx={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 path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

條形圖

總結與感想

關于 SVG 與 React

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

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

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

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

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

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

svg path繪制心形_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/

IMWeb 團隊隸屬騰訊公司,是國内最專業的前端團隊之一。

我們專注前端領域多年,負責過 QQ 資料、QQ 注冊、QQ 群等億級業務。目前聚焦于線上教育領域,精心打磨 騰訊課堂、企鵝輔導 及 ABCMouse 三大産品。

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰

掃碼關注 騰訊IMWeb前端團隊 

svg path繪制心形_SVG 菜鳥的 Recharts 自定義圖表實戰