图表库源码剖析 - 你不知道的 Frappé Charts
原发于知乎专栏:zhuanlan.zhihu.com/ne-fe
最近在可视化社区中,一个简洁美观,没有额外依赖的图表库 Frappé Charts 额外得火,Github 的 Star 数直逼 10000。作者没有选用现有图表方案而选择自行开发的原因来源于自身业务定位与视觉需求,一方面适配研发产品的整体风格,另一方面是由于业务只需要简单的图表。
我们惊讶于 Frappé Charts 的迅速走红,也对其简洁、无依赖的设计充满了好奇,因此@淡苍、@无止休 与 @赵阳 一起对其源码进行了阅读,同时尝试自己动手实践了些最基础的图表 Taco。大家往往会直接使用现成的库去完成业务中的图表研发,我们也是如此,因此在源码阅读与动手实践的过程中,我们精心挑选了若干细节,希望大家通过此文能对基础图表库有更加深入的了解。
整体架构
纵观 Frappé Charts 的整体架构是比较清晰的。基于抽象的 BaseChart 之上,再对含有坐标轴的 Chart 进行了一次抽象,坐标轴体系下的图表主要有 LineChart、ScatterChart 与 BarChart。而非坐标轴的 Chart 则有 PieChart、Heatmap 与 PercentageChart。因为完全没有第三方依赖,因此对于 Frappé Charts 而言,一系列动画函数、计算函数以及渲染函数都需要内置实现。
BaseChart 抽象
让我们先抛开 Frappé Charts,一起来思考一下数据最终是如何转化为图表的。其实可以归结为两个阶段,计算与渲染,计算包括传入数据的预处理、传入数据的加工、坐标轴位置与刻度确定、图形位置确定等等,而渲染就将传入数据经过计算得到的图形数据渲染成为图形,可能是 SVG、Canvas,也可能是其他形式。
Frappé Charts 的 BaseChart 作为核心部分也基于同样的思想。setup 中的 refresh 函数将整个绘制的过程通过一系列的类成员函数进行抽象,其他 Chart 在继承 BaseChart 后,会去覆盖相应的类成员函数来达到实现自身图表的目的。
refresh(init = false) { ... this.set_width(); this.setup_container(); this.setup_components(); this.setup_values(); this.make_graph_components(init); this.make_tooltip(); ... }
注:上图表示了 refresh 中各函数的实际调用,会出现对继承子类(AxisChart、BarChart)的类成员函数直接调用(想想为何可以),而在父类中却没有对应实现(例如:BaseChart 中没有 setup_values 类成员函数),可能会对理解造成一些困扰。
- set_width 确定图表的宽度。
- setup_container 来渲染容器与绘制区域,这是所有图表统一的。
- setup_components、setup_values 对于具有坐标轴的图表而言是计算与绘制坐标轴位置、刻度值,对于其他图表例如 PieChart 与 PercentageChart 则是数据的汇总。
- make_graph_components 是最为核心的图表内容绘制,各个图表均有不同的实现,里面也会附带动画效果,动画部分下文中会再详细介绍。
- make_tooltip 实例化一个 Tooltip 绑定在图表中,这个行为在各图表也均具有。
在我们自己的实现中,我们更强调数据驱动视图的思想,将计算与渲染的完全分离解耦,即计算都完成后再统一进行渲染,而不是边计算边渲染,这样显得更加清晰。
当然图表还需要在 PC 端与移动端下均保持自适应,因此在 Frappé Charts 中,我们对 resize 事件与 orientationchange 事件进行了监听,当其发生改变时,会对重新进行计算与渲染。
bind_window_events() { window.addEventListener('resize', () => this.refresh()); window.addEventListener('orientationchange', () => this.refresh()); }
平面坐标轴
在平面坐标轴的实现上,X 轴相对简单,只需要将图表宽度减去两侧 padding 与 Y 轴宽度后根据传入 labels 数量做等额划分即可。考虑部分 X 轴 label 会过长发生溢出情况,我们需要对文案进行长度判断,对于过长部分用省略号来代替即可。
Y 轴坐标刻度的计算则相对复杂,举个例子,当前 Y 轴数值为 [-25, 89, 1, 90, 107]
,获取 Y 轴的 maxValue(107) 与 minValue(-25) 后,如果只是单纯相减后除以一个刻度线数量的固定值( (107 - (-25)) / 固定值 5
,得到刻度值[-25, 1.4, 27.8, 54.2, 80.6, 107]
),刻度会显得杂乱。那么 Frappé Charts 在刻度划分上先进行了分类讨论:
- maxValue 与 minValue 均大于 0,根据配置判断时在 0 ~ maxValue 还是 minValue ~ maxValue 进行刻度划分。
- maxValue 与 minValue 均小于 0,求绝对值后与大于 0 同理,只是对于最终划分的刻度会进行 reverse 并乘以 -1。
- maxValue 大于 0 而 minValue 小于 0,将对 0 ~ MaxValue 进行刻度划分,计算完成后做 -minValue ~ 0 部分补齐。
对应我们之前的例子属于第三种情况,先求解 0 ~ 107 的刻度划分,maxValue(107) 与 minValue(0) 均为非负整数,我们给出如下刻度计算函数:
function getIntervals(maxValue, minValue) { let [normalMaxValue, exponent] = normalize(maxValue); let normalMinValue = minValue ? minValue / Math.pow(10, exponent) : 0; normalMaxValue = normalMaxValue.toFixed(6); let intervals = getRangeIntervals(normalMaxValue, normalMinValue); intervals = intervals.map(value => value * Math.pow(10, exponent)); return intervals; }
先将 maxValue 用科学计数法(即为下方 normalize 函数)表示
将 minValue 表示为保持指数级一致。maxValue = 1.07 * 10^2,minValue = 0 * 10^2。function normalize(x) { if (x === 0) { return [0, 0]; } var exp = Math.floor(Math.log10(x)); var man = x / Math.pow(10, exp); return [man, exp]; }
降低数量级后将 normalMinValue 向下取整为 0,normalMaxValue 向上取整为 2,求解 exponent = 2 下的整数间隔,为 [0, 0.5, 1, 1.5, 2]
。源码对于 range 的多个值进行了特殊判读,读者可思考一下是否有更优的实现。
function getRangeIntervals(max, min) { let upperBound = Math.ceil(max); let lowerBound = Math.floor(min); let range = upperBound - lowerBound; let noOfParts = range; let partSize = 1; // To avoid too many partitions if (range > 5) { if (range % 2 !== 0) { upperBound++; // Recalc rangerange range = upperBound - lowerBound; } noOfParts = range / 2; partSize = 2; } // Special case: 1 and 2 if (range <= 2) { noOfParts = 4; partSize = range / noOfParts; } // Special case: 0 if (range === 0) { noOfParts = 5; partSize = 1; } let intervals = []; for (var i = 0; i <= noOfParts; i++) { intervals.push(lowerBound + partSize * i); } return intervals; }
最后根据 10^2 进行还原,并补齐负数部分,最终坐标轴结果为 [-50, 0, 50, 100, 150, 200]
。
Frappé Charts 在浮点数计算中会遇到精度问题,例如:#79、#83,可以使用 nefe/number-precision 来处理。
动画
Frappé Charts 中,当图表数据发生变化时,图表会重新执行 set_up 来计算与绘制图表中的各个元素,在计算过程中会对需要执行动画元素的新值与当前值进行差异计算,将结果统一加入 elements_to_animate。在 runSVGAnimation 中对于元素的各个动画属性构建 <animate>
或 <animateTransform>
,<animateTransform>
主要解决了 <animate>
不适合旋转、平移、缩放或倾斜变换的问题。最终构建完成 anim_svg 后,会暂时移除新的 this.svg,插入 anim_svg 完成动画后,再将 this.svg 重新插入。
run_animation() { let anim_svg = runSVGAnimation(this.svg, this.elements_to_animate); this.chart_wrapper.removeChild(this.svg); this.chart_wrapper.appendChild(anim_svg); setTimeout(() => { this.chart_wrapper.removeChild(anim_svg); this.chart_wrapper.appendChild(this.svg); }, 250); }
DOM & SVG & ToolTip
上文提到 Frappé Charts 亮点之一就是零依赖,而 DOM 与 SVG 的操作确是必不可少的。无法借助类似 JQuery、SVG.js 这样的工具库,那么就必须由我们自己来实现。你是否还记得 You-Dont-Need-jQuery?其实一般的操作我们也完全不必去借助工具库。
Frappé Charts 抽象了 dom.js 与 draw.js,进行了简单封装,但在一些做法上我们认为并不妥当,存在改进空间,例如:为了让 SVG 的 text 相对于 line 居中,在 makeYLine 函数中写死了 dy 的值。
function makeYLine(...) { let line = createSVG('line', { x1: startAt, x2: width, y1: 0, y2: 0 }); let text = createSVG('text', { x: textEndAt, y: 0, dy: '.32em', ... }); ... }
ToolTip 是图表中不可获缺的辅助信息,上文提到在核心流程中会有 make_tooltip 函数,它将实例化一个SvgTip,SvgTip 会在图表容器下插入一个隐藏的 div,之后执行不同图表的 bind_tooltip,对图形的 mouseenter 事件进行监听,一旦触发后,将相应位置信息与图形数据信息传给 SvgTip,SvgTip 完成最终显示。
写在最后
优点 | 缺点 | |
---|---|---|
Frappé Charts | 1. 设计简洁 2. 配置方便,使用成本低 3. 无外部依赖 4. 使用 ES6 | 1. 无单元测试与集成测试 2. 复杂图表能力弱 3. 扩展能力弱 |
对于 Frappé Charts,我们并不认为它是一个非常完美的库。简洁的设计与较低的使用成本,无法掩盖它在图表支持能力、扩展能力与稳定性上的不足,不过在即将到来的 0.1.0 版本中,我们也看到作者对图表组件进行了一些重构,更贴近于 OOP。BaseChart 中的主流程将得到重新梳理,更好地遵循生命周期。混合图表与多重坐标轴也将得到支持。
Frappé Charts 提供的基础图表构建思路值得我们借鉴,基础图表研发看似简单,却需要我们考虑非常多的细节,作为一个新兴图表库,没有太多历史包袱,学习成本也相对较低,有兴趣的读者也可以自行尝试阅读源码。当前 Frappé Charts 还在不断更新与迭代,我们也会保持持续关注,如果想来和我们一起研究可视化,欢迎投递简历 [email protected]