Recharts 是一款图表处理的类库,利用 React 的特性,从新定义了图表的配置和组合方式,大大地提升了图表自定义样式的灵活度。本文记录了使用 Recharts 结合 SVG 开发自定义样式图表的踩坑历程。git
ABCmouse 学校版 为老师们提供了孩子学习状况反馈的模块,其中有一部分数据须要以图表的方式直观展现。github
这也涉足到了数据可视化的领域。这个领域细节繁多,靠我的力量难以考虑周全,便须要依赖第三方组件库。结合这一个需求,在数据可视化组件库的选择上,主要考虑两点:canvas
通过一番调研,选择用 Recharts[1] 实现上述的图表。bash
Recharts 是一个处理图表的类库,re 的含义除了 "React" 外,还表明 "Redifined",从新定义图表各元素的组合和配置的方式。它基于 React 和 D3 构建,具备如下特色:echarts
声明式的标签,让写图表和写 HTML 同样简单
框架
贴近原生 SVG 的配置项,让配置项更加天然
ide
接口式的 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]。组件化
本文接下来的部分,记录使用它在实现饼图与条形图中,遇到的细节问题和实现的过程。
如图,这里的饼图的圆环部分,使用了 PieChart 组件,中间的文字和图例则直接使用 HTML 渲染,不依赖 Recharts。
这里简单地介绍一下 Recharts 实现放大的圆环部分、引导线和 Label 的过程,为你带来一个对 Recharts 直观印象。
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} /> );}复制代码
完成圆环部分放大的效果:
找了一圈 Recharts 的文档没有发现引导线的组件, 官网例子 的引导线是一段嵌套了 svg 元素的代码,做者在作这个需求以前还没仔细研究过 svg 图形。怎么办呢?学!
开始一波网上冲浪,找到了 MDN 的 SVG 教程[4],过了一遍,有了个基础印象。在引导线的实现上用了 <path>
元素。
<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>
还提供了 stroke
和 fill
属性,分别对应着边框和填充的颜色,path 本质上是一个闭合路径造成的形状,咱们画的图本质上属于边框,所以颜色设置上也是须要用 stroke
来作,具体参考 MDN 关于 Stroke 和 Fill 的介绍[5]。
设计同窗须要虚线的引导线,SVG 提供了 stroke-dasharray
实现这个需求,它接受一组逗号分隔的数字,这个数字表明着线长和空白的长度的组合。
到这里,绘制图形须要的原料基本梳理清楚了。
咱们的目标是在 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;复制代码
这时咱们渲染出了想要的引导线:
这一步比较简单,用 SVG 的 <text>
元素处理就好,把上一步引导线用的 (ex, ey) 做为文字的起始坐标,再考虑一下 textAnchor
保证对齐方向便可。
最终的饼图效果。
如图,这里咱们须要作这样的一个条形图,涉及到的元素有两块,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>复制代码
获得以下效果:
到了这一步,咱们距离最终目标还差条形图的标签,渐变和圆角的顶部。
首先咱们解决渐变的问题,查找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~
接下来咱们实现圆角的顶部,它本质上是一个封闭的 <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) 指的是柱子左上角的坐标。
加上圆角后的效果:
上面的实现是数据比较均衡的状况,当数据差别悬殊的状况下,便暴露出一个让人心态炸裂的问题,很少说,看下图。
咱们想实现一个圆角矩形,但 (x, y) 其实是位于半圆的左边空白部分的左上角。当这个点太接近坐标轴,加上圆角半径之后,圆角的起点的纵坐标便超出范围,致使了这种诡异的状况。能不能把它隐藏起来呢?
怎么能不能够!继续网上冲浪,找到 SVG 的剪切功能[7],刚好 recharts 生成的 SVG 也有 <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)`} /> );}复制代码
一样的思路,咱们直接在 <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> );};复制代码
在作这个需求时也开始直接入门了 SVG,掌握了新的一门控制视觉展现的技术,满满的收获~
React 直接渲染 SVG 也进一步打开了个人眼界,原来她不只能够渲染 HTML 元素,也能够直接撸 SVG,在实现了适配层的状况下,咱们还能够搞 canvas、Native 渲染,甚至嵌入式设备的液晶屏也能够用[9]。经过 React 实现一套代码在不一样的平台上构造许多复杂的 UI 逻辑,让我实实在在地感觉到了这样的抽象的威力所在。
假期看了 SICP 课程[10],它讨论了许多关于“抽象”的话题。咱们为一些复杂的事情创建抽象屏障,避免了咱们的精力被各类重复的杂事给占据。
抽象的目的在于隐藏背后的复杂,创造抽象屏障的本质上也同时创造出一种新的沟通方式,某种意义上能够说是一种“语言”。
让人新把握一门“语言”实际会给人带来负担,但通常状况下咱们察觉不到。当这样的抽象复杂到了必定程度,这样的负担便开始显现出来。每每咱们的需求并不能被一层抽象知足,而常常去跨越一层层的抽象屏障。
跨越多层抽象屏障,也就意味着须要同时把握更多的“语言”以及它们之间的千丝万缕关系,致使复杂度大大增长,无形中就带来了许多的坑。
想以抽象的方式去归纳复杂的现实,设计上必然会有所侧重。这是个矛盾的问题,相似 ECharts 这样侧重于简单配置的图表可视化组件,若是尝试去作精细的定制改造,难度将会很是大;Recharts 更侧重于定制化,它为咱们提供了能直接触及到最终 UI 展示的方式,借助于 React,定制的过程也足够简单。咱们作组件库选型的时候,得考虑目标在不一样维度之下的比较和权衡,根据需求在其中的侧重之处,作最合适的选择。