首发于Dear Data
【深度好文】可视化库的设计空间

【深度好文】可视化库的设计空间

文章来源:medium.com/nightingale/

图形学和可视化开发者经常会面临一个简单而又复杂的问题:“我到底应该使用哪个可视化库?”

通常来说,这不是一个关于“哪个库更优秀”的问题,而是“哪个库更适合你”的问题。

为了彻底回答这个问题,我们先理解一个概念——“可视化库的设计空间”。基于对Web库的调研,我们从以下两个维度来描绘可视化库的大图:

  • 抽象层次:可以分成两个方面去理解,一方面是开发者的使用成本。抽象层次越高, 需要理解的概念和开发的代码量越少。另一方面是可表达性,或者说可定制性。抽象层次越高,可定制性越低。
  • API设计:库设计者定义代码的使用方式

API设计

先从API设计开始说起。后面讲完抽象层次之后,你会更理解各种API设计形式。相同抽象层次的库可能会提供不同形式的API。不要混淆API设计和库的抽象层次,这是两个不同的概念。

  • 很多可视化库提供原生JS API,它们不依赖React, Vue, Angular等框架。例如D3,它就不依赖任何框架库。不依赖前端框架的好处在于灵活性,它们可以用于任何项目。但是这样的代码会更加命令式,就像机器命令一样;而不是声明式,更接近于人们的直观感受。
const chart = new Chart();
chart.addAxis(new XAxis(...));
chart.addSeries(new LineSeries(...));
  • 有些库,例如 Vega,所有的API都是JSON配置。JSON配置避免了命令式的开发模式,因为它们无法接受函数或者自定义对象作为入参。这样的约束也定义了更强声明式的API。这也意味着JSON配置很容易被序列化存成文本,或被命令行工具使用。 但是,这种库很难被集成。
{ "x": "time", "y": "price", "series": [ ... ] }
  • 有些库,例如 ECharts,介于两者之间,提供了能回调的混合JSON配置。相对于纯JSON。这种库可以将整体API定义成一个JS配置对象,可以带有函数和非原始值。这样的简单配置,表面上看起来就像一个普通的JSON。但是,新增的函数支持带来了更高的定制性,更大的自由度,以及与其他库更容易的整合。
{ "x": d => d.time, "y": d => d.price, "series": [ ... ] }
  • 剩余的库是与框架(如React)强绑定的,它们能更好地融合于项目。比如,在React框架的项目中用React的可视化库会更自然,相比于D3这种另类代码块来说,代码更加一致也更有优化空间。缺点在于开发者需要框架相关的理论知识,也只能限于在该类框架中使用。
<Chart>
  <XAxis />
  <LineSeries />
</Chart>

一些库也提供了不同版本的API。比如deck.gl有原生版本@deck.gl/core、React 版本@deck.gl/react、JSON配置版本@deck.gl/json。

抽象层次

抽象层次大致对应着开发一个图表的难易程度和可表达性。也就是说,抽象层次越高,开发一个图表需要的代码越少,同时你能自定义的地方也越少。反之,抽象层次越底的库自定义空间越大,同时你的开发成本也越高。

抽象层次类比乐高

可组合搭建单元(Composable Building Blocks level 2–4)是一些可以组合出图表的基本单元。如果说使用图形操作库(graphics libraries)就像用橡皮泥造房子,那么用可组合搭建单元就像用一箱乐高造房子。你可以用任意方式组装乐高。唯一限制在于你只能基于你拥有的乐高形状。你也可以同时使用不同的积木,只要它们之间是兼容的。

如果你不想直接使用图表组件库(chart template level 5),但还是想借力而不是从零开始开发图表,那可组合搭建单元是最佳的方案。

1. 图形操作库(Graphics Libraries)

这类库需要开发者直接绘制可视化元素,或者在CG方面直接新建场景、打光影等。它们更接近原生的API,例如CanvasWebGL。它们可表达能力最强,同时开发成本也最高。如果你就想快速绘制一个柱图,不要用这个。但是,这类库可以让你开发出高性能、超级炫酷的图形,这是其他类库无法做到的。

react-three-fiber的例子 这类库有: Processing, p5*js, Raphael, Rough.js, three.js, PhiloGL, luma.gl, two.js, PixiJS, react-rough, react-three-fiber

举个例子,用p5绘制一个小矩形的画布就需要这么多代码:

// 用原生JS API的p5.js画矩形
import p5 from 'p5';

const p = new p5(function(sketch) {
  sketch.setup = () => {
    sketch.createCanvas(200, 200);
  };
  sketch.draw = () => {
    sketch.background(0);
    sketch.fill(255);
    sketch.rect(100, 100, 50, 50);
  };
});

而用react-though画矩形是这样的。有些高抽象层次的库用这些代码量甚至可以画出线柱图了。

<ReactRough>
  <Rectangle x={15} y={15} width={90} height={80} fill="red" />
</ReactRough>

2. 低抽象搭建单元(L_ow-level Building Blocks _)


基础乐高积木

低抽象搭建单元非常独立也非常灵活。这类库中的组件有各自明确的作用,可以组合使用(也可和其他库组合)创造出图表。组合方式有大致的约束条件,留给开发很大的发挥空间。

最经典的例子就是D3,从早期其他语言的框架(Prefuse, Flare)演进而来。过去十年,D3完全改变了可视化的视野,从DOM、SVG等标准中借力引入了 selection, scales, formatting等的一套底层组件工具集,而不是完全自己定义结构。

下面这个例子,是用D3提供的一些搭建单元(scales、selection)组合出了一个简单的柱图。

// 用D3创建一个柱图
const x = d3.scaleBand().rangeRound([0, width]);
const y = d3.scaleLinear().range([height, 0]);
const svg = d3.select("svg").attr("width", width).attr("height", height);

x.domain(data.map(d => d.date));
y.domain([0, d3.max(data, d => d.value)]);
svg.selectAll("bar")
    .data(data)
  .enter().append("rect")
    .style("fill", "steelblue")
    .attr("x", d => x(d.date))
    .attr("width", x.band())
    .attr("y", d => y(d.value))
    .attr("height", d => (height - y(d.value)));

除了D3之外,很多库也提供了独特的组件和工具箱。虽然其中很多都带了d3前缀,不是所有的库都需要依赖D3。比如:

  • colaCytoscape提供了各式各样的图表布局算法
  • d3-annotation专精于图表标注能力
  • d3-cloud提供了词云图算法
  • d3-legend可以根据scale绘制完美的图例
  • flubber可以在2D形状间平滑地切换变形
  • labella 协助你在时间轴上随意放标签
  • visx 提供了封装D3和SVG的React搭建单元

3. 图形语法库(Visualization Grammars

从图纸上可以看出, 一个乐高小人偶包含8个身体零件。任何的一个乐高小人偶都可以用这8种类型的零件拼装出来。

介于高抽象搭建单元和底抽象搭建单元之间是图形语法库。图形语法追根于1990年代出版的书籍《The Grammar of Graphics》,它给如何设计统计图表提供了新的视角。图形语法引入了一个通用概念模型绘制图表,将图表抽象成一个通用的模型,然后进行组合,而不是根据传统的图表分类——柱图、饼图、散点图还是气泡图去绘制。

与英语等语言的语法如何定义词性(名词、动词等)并给出将这些词性组合成有意义的句子的结构类似,图形语法也定义了自己的部分,并提供了一种将它们组合起来描述输出图形的结构。这种严格的结构正是它们区别于低抽象搭建单元的原因。

统计图表使用可以分成以下六个部分来描述:

  • DATA:从数据集创建变量的一些操作
  • TRANS:各种数据转换(例如排序)
  • SCALE:数量级变换(例如log)
  • COORD:坐标系(例如极坐标)
  • ELEMENT:图形(例如点)以及对应的视觉属性(颜色等)
  • GUIDE:一些辅助元素(例如坐标轴、图例等)

观察如下图表以及图形语法上的说明。该图被分解成DATA、SCALE、COORD和 ELEMENT的组合。

ggplot2是图形语法最经典的实现,统治了R语法和数据科学社区。在Web领域,Vega用JSON配置形式开发图表,用HTML5 Canvas或者SVG生成交互式视图。 Vega-Lite提供了更抽象的等价于ggplot2的交互语法,最终被编译成Vega并渲染。

以下代码是用Vega-Lite开发的条形图。data数据集单独描述,mark和encoding字段相当于图形语法中的ELMENT元素和样式。

// 用Vega-Lite开发柱图,只需要用一个纯JSON来描述
{
  "$schema": "https://vega.github.io/schema/vega-lite/v4.json",
  "description": "A simple bar chart with embedded data.",
  "data": {
    "values": [
      {"country": "China", "population": 131744}, 
      {"country": "India", "population": 104970},
      {"country": "US", "population": 29034}
    ]
  },
  "mark": "bar",
  "encoding": {
    "x": {"field": "population", "type": "quantitative"},
    "y": {"field": "country", "type": "nominal"}
  }
}

相对于Vega-Lite提供的JSON API,G2Muze提供了原生的图形语法API, Chart Parts则是React版本的实现。以下是G2代码实现一个条形图,注意它和Vege-Lite在API设计上的区别。

// 用G2的原生JS API 创建柱图
import { Chart } from '@antv/g2';

const data = [
  {country: "China", population: 131744}, 
  {country: "India", population: 104970},
  {country: "US", population: 29034}, 
];

const chart = new Chart({ container: 'container', autoFit: true, height: 500 });
chart.data(data);
chart.coordinate().transpose();
chart.scale('population', { nice: true });
chart.interval().position('country*population');
chart.render();

4. 高抽象搭建单元(High-level Building Blocks)

提前组装好的乐高块,仍然需要组合在一起来搭建出洗手间

如果低抽象搭建单元相当于乐高块,可以非常自由地进行各式各样的组合,这些高抽象搭建单元就像提前组装好的较大模块。

与图形语法类似,高抽象搭建单元需要以一定的规则和其他组件组合才能创造出一个图表。不过,两者之间有些常见的不同之处:

  • 有些图表库将坐标轴和数据量变换结合起来。而在图形语法中SCALE是一个组件,而坐标轴只是GUIDE中的一部分;
  • 高抽象搭建单元库会在各种地方灌入数据,通常是随着样式系列设置中。而图形语法将数据处理(DATA)和数据转换(TRANS)分开,只有在ELEMENT 模块中会引用字段名或者变量;
  • 它是更宽泛意义上的“无图表类型”,也会包含一些为更加复杂图表而封装特殊逻辑的系列或者图层,比如河流图。这种其实更接近第5个分类——图表组件库。不过,它仍然不像图表组件库那样添加一个图表类型就能开发出一个完整的图表。

举个例子,箱线图可以用图形语法描述成柱图和线图的图层组合。在高抽象层次的图表库,可能为了方便起见,会定义一个组合两个图层并且封装交互逻辑的CandlestickSeries组件。然后用CandlestickSeries图层再加上坐标轴、网格线,即可创建出箱线图。而图表组件库会直接提供一个包含坐标轴、网络线等所有元素的CandlestickChart组件,开发者只需要灌入数据即可。

EChartsHighchartsPlotly之类的库会将JSON结合回调函数。以下例子是一个简单的例子,配置看起来像普通JSON。

// 用Echarts创建箱线图
option = {
  xAxis: {
    data: ['2017-10-24', '2017-10-25', '2017-10-26', '2017-10-27']
  },
  yAxis: {},
  series: [{
    type: 'candlestick',
    data: [
      [20, 30, 10, 35],
      [40, 35, 30, 55],
      [33, 38, 33, 40],
      [40, 40, 32, 42]
    ]
  }]
};

后来的一些库,比如 VictoryReact-VisSemiotic都是基于React框架了。它们提供<XYPlot/><LineSeries/><XAxis/>等组件,可以组合成用户想要的可视化效果。

// 用React API的Victory库创建的箱线图
<VictoryChart
  theme={VictoryTheme.material}
  domainPadding={{ x: 25 }}
  scale={{ x: "time" }}
>
  <VictoryAxis tickFormat={(t) => `${t.getDate()}/${t.getMonth()}`}/>
  <VictoryAxis dependentAxis/>
  <VictoryCandlestick
    candleColors={{ positive: "#5f5c5b", negative: "#c43a31" }}
    data={sampleDataDates}
  />
</VictoryChart>

对比Echart的第8行,Victory的9-12行,以及下面Vega-Lite的25-40行,我们可以看到箱线形状在前两者中是一个单独的系列,而后者被描述成了两个标注层。

{
  "$schema": "https://vega.github.io/schema/vega-lite/v4.json",
  "width": 400,
  "data": {"url": "data/ohlc.json"},
  "encoding": {
    "x": {
      "field": "date",
      "type": "temporal",
      "title": "Date"
    },
    "y": {
      "type": "quantitative",
      "scale": {"zero": false},
      "title": "Price"
    },
    "color": {
      "condition": {
        "test": "datum.open < datum.close",
        "value": "#06982d"
      },
      "value": "#ae1325"
    }
  },
  "layer": [
    {
      "mark": "rule",
      "encoding": {
        "y": {"field": "low"},
        "y2": {"field": "high"}
      }
    },
    {
      "mark": "bar",
      "encoding": {
        "y": {"field": "open"},
        "y2": {"field": "close"}
      }
    }

另外一个经典的高抽象搭建单元的例子是 deck.gl,它的各个地图层可以组合出各种地图可视化效果。

// 用deck.gl的JS API 创建气泡地图。deck.gl提供很多图层叠加在地图底图之上,其中一种就是气泡层
import {Deck} from '@deck.gl/core';
import {ScatterplotLayer} from '@deck.gl/layers';

const INITIAL_VIEW_STATE = {
  latitude: 37.8,
  longitude: -122.45,
  zoom: 15
};

const deckgl = new Deck({
  initialViewState: INITIAL_VIEW_STATE,
  controller: true,
  layers: [
    new ScatterplotLayer({
      data: [
        {position: [-122.45, 37.8], color: [255, 0, 0], radius: 100}
      ],
      getColor: d => d.color,
      getRadius: d => d.radius
    })
  ]
});

5. 图表组件库(Chart Templates)

图表模板就像这些复杂的乐高模型

这类图表库可以只有一个图表组件,也可以数百个图表组件。每个组件通过图表类型来引用,比如Bar(柱图)、Pie(饼图)、Area(面积图)、Stacked Bar(堆积柱图), Waterfall(瀑布图), Bump, Calendar(日历图), Treemap(矩阵树图), Marimekko, Sunburst(旭日图), ColumnWithLine(线柱组合图), Dual line(双折线图) etc.

图表库的最大优点在于开箱即用,上手方便。开发者只需要选择一种图表类型,将准备好的数据处理成要求的格式灌入即可。

用图表组件库只需要看一下是否提供相应的图表组件,而不需要学习怎么用图形语法来描述一个图,或者学习怎么用D3开发。如果存在这个图表,就用,不存在就换个图表库看看。

另外,比较新颖的图表类型(比如刚研究出来的新技术)通常是一个图表一个库的。

举例:

// 用Chart.js创建雷达图
const myRadarChart = new Chart(ctx, {
  type: 'radar',
  data: data,
  options: options
});
// 用nivo的React API创建日历图

import { Calendar } from '@nivo/calendar';

<Calendar
  data={[
    { "day": "2016-02-05", "value": 397 },
    { "day": "2015-09-17", "value": 283 }, 
  ]}
  from="2015-04-01"
  to="2016-12-12"
  emptyColor="#eeeeee"
  colors={[ '#61cdbb', '#97e3d5', '#e8c1a0', '#f47560' ]}
/>

可视化库的抽象程度是一个连续的谱图,而不是离散的分层。所以,你可能会遇到一些介于两者之间的库。重要的不是层级之间语法区别,更在于开发者选择一款符合自己场景的图表库的能力。实际上,有些库甚至提供了几种不同抽象层次的封装。例如:

  • dc.js 既有图表组件,又有高抽象搭建单元
  • G2Plot 是基于图表语法库G2上的图表组件库
  • react-vis 既有高抽象搭建单元(),也有图表组件()
  • 而D3事实上也是穿梭于几个层级之间。例如,d3-scale 从图形语法的层面定义了scale的模块;而 d3-shape更接近于底层图形库。

发散性总结

总而言之,本文的目的在于将可视化库提供一个划分空间的依据,并提供一个能更理解这些图表库的底层框架理论,无论你是只想使用还是新开发一个图表。我们把所有的可视化空间划分成最底层的图形操作库到最高封装的开箱即用的图表组件库。

本文中提到了很多可视化库,但这不是所有的图表库。我尽量描述好每个层级的特征,然后选择一些著名的图表库来举例。本文也仅仅关注于Web端的可视化库,其他语言和其他平台后续我也会去探讨是否存在这些的分类方式。

如果你思考用哪个可视化库,那就先从你的时间、你的编码习惯、任务本身以及库的目标用户等角度来决定库的抽象层次。选择好哪种抽象层次的库之后,再去看看API设计以及其他可能需要考虑的因素:

  • 渲染技术:用的是SVG,Canvas,还是WebGL
  • 性能:包大小,速度,服务端渲染等
  • 其他:类型安全,证书,主题风格,动效等

希望你看完这篇文章之后能有所收获。可能下次你再遇到一个新的可视化库时,能用上这个框架去分析它,也能对比出这个库和你已知的那些图表库相似或者不同之处。


P.S. 位于Chart Template、Framework-specific的Recharts是我们团队的开源库,已经在全世界被使用,目前月均下载量达到 130万次。对可视化感兴趣的有志之士可以移步流形:阿里数据中台团队招聘前端,欢迎投递简历。

编辑于 2020-10-26 09:43