Appearance
实战-渲染流程 - Plot
经过前面的学习,我们已经把渲染引擎和低级可视化模块开发完了,也了解了对应的可视化概念。那么这一章我们就把已经开发好的这些模块串起来,完成“从0到1开发一个图表库”的这一任务。
这一章我们将从 Sparrow 的 API 介绍开始,然后梳理渲染流程,这之后再开始介绍关键代码。这一章可以说是整个实战环节的画龙点睛之笔,那么接下来就让我们开始吧!
API 设计
首先我们来看看 API 设计,也就是了解一下该如何使用我们最后完成 Sparrow。
Sparrow 最终只暴露出一个函数:plot
。该函数根据指定的 options 渲染图表并且返回一个渲染好的 SVG 元素。函数签名可以用 TypeScript 简单地如下定义:
javascript
plot(options: SPSpec): SVGSVGElement
至于这个 options 的结构用 TypeScript 可以简单地如下定义:
ts
type SPSpec = SPNode;
type SPNode = {
type?: string;
data?: any[],
scales?: Recode<ChannelTypes, Scale>,
transforms?: Transform[],
statistics?: Statistic[],
encodings?: Recode<ChannelTypes, Encode>,
guides?: Recode<ChannelTypes, Guide>,
styles?: Record<string, string>
children?: SPNode[];
paddingLeft?: number,
paddingRight?: number,
paddingTop?: number,
paddingBottom?: number,
}
可以发现:它是一个嵌套的结构,描述的是上一章提到的视图树。
每一个节点的 type 除了上一章提到的 layer、col、row 这些容器节点之外,还可以是所有几何元素的类型:interval、area、text 等等,这些被称为视图节点,当然上一章提到的 facet 节点也算是一个视图节点。容器节点可以有 children 属性,但是视图节点不能有 children 属性。
下面对上面的节点的一些属性进行解释:
- data:任意类型的数据。
- scales:比例尺的配置,比如:
{type: 'ordinal', range: ['red', 'yellow']}
- transforms:数据预处理配置,比如:
data => data.sort()
- statistics:统计函数配置,比如:
{type: 'stackY'}
- encodings:指定几何元素的每个通道用什么编码,比如:
{x: 'genre', y: 'sold'}
- guides:指定辅助组件的配置,比如:
{type: 'axisY', display: false}
- styles:指定几何元素的样式,比如:
{strokeWidth: 10}
- paddingLeft:几何图形区域到整个图表区域的左边距。
- paddingRight:几何图形区域到整个图表区域的右边距。
- paddingTop:几何图形区域到整个图表区域的上边距。
- paddingBottom:几何图形区域到整个图表区域的下边距。
对于容器节点来说,上面的属性对其没有效果,但是会被后代中视图节点继承。比如下面两种写法其实是等价的。
javascript
// 可以理解为是下面的语法糖
const options = {
type: 'layer',
encodings: {x: 'name', y: 'value'}
children: [
{type: 'point'},
{type: 'line'}
],
}
const options = {
type: 'layer',
children: [
// encodings 这个配置继承于父亲
{type: 'point', encodings: {x: 'name', y: 'value'}},
{type: 'line', encodings: {x: 'name', y: 'value'}}
],
}
有了上面的介绍,接下来来看一个简单的例子。
javascript
const sports = [
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
];
如果我们要用 Interval 去可视化上面的数据,那么我们将如下使用 Sparrow:
javascript
import { plot } from '@sparrow-vis/plot';
plot({
type: 'interval', // 指定节点的种类是 interval
data: sports, // 指定数据
encodings: {
x: 'genre', // 指定 x 通道由数据的 genre 属性决定
y: 'sold', // 指定 y 通道由数据的 sold 属性决定
},
});
最后的效果如下图:
接下来我们来看一个稍微复杂一点的例子,同样是上面的例子,这次我们来绘制一个饼图。
javascript
import { plot } from '@sparrow-vis/plot';
plot({
type: 'interval',
data: sports,
// 将数据的 sold 字段转换成百分比形式
transforms: [(data) => {
const sum = data.reduce((total, d) => total + d.sold, 0);
return data.map(({ genre, sold }) => ({ genre, sold: sold / sum }));
}],
// 使用两个坐标系变换:transpose 和 polar
coordinates: [{ type: 'transpose' }, { type: 'polar' }],
// 使用一个统计变换 stackY
statistics: [{ type: 'stackY' }],
// 设置 x 通道使用的比例尺的 padding 属性
// interval 的 x 通道必须使用 band 比例尺,所以有 padding 属性
scales: {
x: { padding: 0 },
},
guides: {
x: { display: false }, // 不显示 x 方向的坐标轴
y: { display: false }, // 不显示 y 方向的坐标轴
},
encodings: {
y: 'sold', // y 通道和 sold 属性绑定
fill: 'genre', // fill 通道和 genre 属性绑定
},
// 设置饼图的样式
styles: {
stroke: '#000',
strokeWidth: 2,
},
})
最后的效果如下:
了解完了 API 设计,接下来就来看看渲染流程,看看 plot 函数是如何将配置转换成 SVG 元素的,或者是如何把数据转换成像素点的。(更多的使用方式可以参看这里的测试代码)。
在开始看代码之前,大家可以先去 Sparrow 的官网看看案例,了解一下 Sparrow 的具体使用方式,然后可以先想想自己会如何去实现。 这之后再读代码的话可以做到事半功倍的效果。
渲染流程
Sparrow 整个的渲染流程主要分为下面几个阶段:
- 预处理:视图节点继承祖先容器节点的属性,同时合并同一区域的属性。
- 获取通道值:
- 通过 transforms 函数转换数据,获得需要可视化的表格数据。
- 根据编码 encodings 配置从数据中去提取几何图形每个通道对应的值。
- 通过 statsitcs 函数处理获得的通道值,获得最后真正被可视化出来的通道值。
- 创建比例尺:根据当前的通道值以及 scales 配置去推断对应比例尺 种类,定义域和值域的值。
- 创建辅助组件:根据推断出来的比例尺以及 guides 配置去创建对应的辅助元素。
- 创建坐标系:根据 coordinates 配置去创建对应的坐标系。
- 绘制:
- 绘制辅助组件。
- 绘制几何元素。
我们通过上面饼图的例子来展示一下这个流程。最开始的数据如下:
javascript
const data = [
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
];
首先数据会经过如下 transforms 函数的转换,这里面的转换会被合成一个函数。
javascript
plot({
// ...
transforms: [(data) => {
const sum = data.reduce((total, d) => total + d.sold, 0);
return data.map(({ genre, sold }) => ({ genre, sold: sold / sum }));
}]
// ...
})
这一步之后的数据如下:
javascript
const transformedData = [
{ genre: 'Sports', sold: 0.2722772277227723 },
{ genre: 'Strategy', sold: 0.11386138613861387 },
{ genre: 'Action', sold: 0.1188118811881188 },
{ genre: 'Shooter', sold: 0.3465346534653465 },
{ genre: 'Other', sold: 0.1485148514851485 },
];
数据转换之后将会根据 encodings 去提取数据。
javascript
plot({
// ...
encodings: {
y: 'sold', // y 通道和 sold 属性绑定
fill: 'genre', // fill 通道和 genre 属性绑定
},
// ...
})
根据如上的配置会得到如下的结果:
javascript
const values = {
// fill 和 'genre' 字段绑定,所以提取出来是 'genre' 字段的值
fill: ['Sports', 'Strategy', 'Action', 'Shooter', 'Other'],
// 没有指定 x 通道的值,默认为 0
x: [0, 0, 0, 0, 0],
// 没有指定 x 通道的值,默认为 0
y1: [0, 0, 0, 0, 0],
// y 和 'sold' 字段绑定,所以提取出来是 'sold' 字段的值
y: [0.2722772277227723, 0.11386138613861387, 0.1188118811881188, 0.3465346534653465, 0.1485148514851485]
};
这之后就会就会经过 statistics 去处理数据。
javascript
plot({
// ...
statistics: [{ type: 'stackY' }],
// ...
})
处理后的数据如下,可以发现 y 方向的通道已经被堆叠过了。这个阶段获得的 transformedValues
就是获得的通道值。
javascript
const transformedValues = {
// fill 和 'genre' 字段绑定,所以提取出来是 'genre' 字段的值
fill: ['Sports', 'Strategy', 'Action', 'Shooter', 'Other'],
// 没有指定 x 通道的值,默认为 0
x: [0, 0, 0, 0, 0],
// 没有指定 x 通道的值,默认为 0
y1: [0, 0.2722772277227723, 0.38613861386138615, 0.504950495049505, 0.8514851485148515],
// y 和 'sold' 字段绑定,所以提取出来是 'sold' 字段的值
y: [0.2722772277227723, 0.38613861386138615, 0.504950495049505, 0.8514851485148515, 1]
};
接下来就是根据获得的通道值创建比例尺了。
javascript
plot({
// ...
scales: {
x: { padding: 0}
},
// ...
})
下面只展示了根据通道值和 scales 配置推断出来的比例尺比较重要的属性。这里的推断规则会后面介绍。
javascript
const scaleDescriptors = {
// stroke 和 fill 通道都是用 color 比例尺
color: {
domain: ['Sports', 'Strategy', 'Action', 'Shooter', 'Other'],
range: ['#5B8FF9', '#5AD8A6', /* ... */]
type: 'ordinal',
},
// x 方向的通道(x1、x)都使用 x 比例尺
x: {
domain: [0],
range: [0, 1],
type: 'band'
},
// y 方向的通道(y1、y)都使用 y 比例尺
y: {
domain: [0, 1],
range: [1, 0],
type: 'linear'
}
}
这之后会根据 scaleDescriptors 和 guides 的配置去推断 guidesDescriptors。
javascript
plot({
// ...
guides: {
x: { display: false }, // 不显示 x 方向的坐标轴
y: { display: false }, // 不显示 y 方向的坐标轴
},
// ...
})
最后得到的 guidesDescriptors 如下:
javascript
const guidesDescriptors = {
// color 通道的辅助组件是 legendSwatches
// x 和 y 因为都设置为 display: false 了,所以不现实
color: {
domain: ['Sports', 'Strategy', 'Action', 'Shooter', 'Other']
label: 'genre',
type: 'legendSwatches',
x: 45,
y: 0
}
}
这之后创建坐标系,绘制辅助组件和几何图形就没有太多需要说的地方了。接下来就进入我们的写代码环节:因为 Plot 这个模块一共有 500 多行代码,所以就不全部在文章中讲解了,这里只会讲解一些比较重要的部分。
plot
我们首先从 plot 函数开始,该函数会预处理我们的配置,然后解析描述的视图树,将嵌套的视图树拍平成一个视图树组,最后通过 plotView 函数绘制每一个视图。
javascript
// src/plot/plot.js
import { createViews } from '../view';
import { createRenderer } from '../renderer';
import { createCoordinate } from '../coordinate';
import { create } from './create';
import { inferScales, applyScales } from './scale';
import { initialize } from './geometry';
import { inferGuides } from './guide';
import { bfs, identity, map, assignDefined } from '../utils';
export function plot(root) {
// 创建渲染引擎
const { width = 640, height = 480, renderer: plugin } = root;
const renderer = createRenderer(width, height, plugin);
// 将配置从容器节点流向视图节点
flow(root);
// 将视图树转换成视图树组
const views = createViews(root);
for (const [view, nodes] of views) {
const { transform = identity, ...dimensions } = view;
const geometries = [];
const scales = {};
const guides = {};
let coordinates = [];
const chartNodes = nodes.filter(({ type }) => isChartNode(type));
// 合并同一区域的所拥有视图的配置
for (const options of chartNodes) {
const {
scales: s = {},
guides: g = {},
coordinates: c = [],
transforms = [],
paddingLeft, paddingRight, paddingBottom, paddingTop,
...geometry
} = options;
assignDefined(scales, s); // 合并 scales 配置
assignDefined(guides, g); // 合并 guides 配置
// 合并 padding 等配置
assignDefined(dimensions, { paddingLeft, paddingRight, paddingBottom, paddingTop });
if (c) coordinates = c; // 使用最后一个视图的坐标系
// 收集该区域的所有几何图形
geometries.push({ ...geometry, transforms: [transform, ...transforms] });
}
// 绘制每一个区域
plotView({ renderer, scales, guides, geometries, coordinates, ...dimensions });
}
// 返回 SVG 元素
return renderer.node();
}
javascript
// src/plot/plot.js
function flow(root) {
bfs(root, ({ type, children, ...options }) => {
if (isChartNode(type)) return;
if (!children || children.length === 0) return;
const keyDescriptors = [
'o:encodings', 'o:scales', 'o:guides', 'o:styles',
'a:coordinates', 'a:statistics', 'a:transforms', 'a:data',
];
for (const child of children) {
for (const descriptor of keyDescriptors) {
const [type, key] = descriptor.split(':');
if (type === 'o') {
child[key] = { ...options[key], ...child[key] };
} else {
child[key] = child[key] || options[key];
}
}
}
});
}
javascript
// src/plot/plot.js
function isChartNode(type) {
switch (type) {
case 'layer': case 'col': case 'row': return false;
default:
return true;
}
}
plotView
接下来我们来看看 plotView 函数,该函数是真正把图表渲染出来的地方。
在这个流程中有两个函数比较关键:第一个就是 initialize
函数,这是获取每个几何图形通道值的地方;第二就是 inferScales
这个函数,这是给每个通道选择比例尺的地方,只要比例尺选择对了,那么绘制的几何图形就基本上没有问题了。
javascript
// src/plot/plot.js
function plotView({
renderer,
scales: scalesOptions,
guides: guidesOptions,
coordinates: coordinateOptions,
geometries: geometriesOptions,
width, height, x, y,
paddingLeft = 45, paddingRight = 45, paddingBottom = 45, paddingTop = 60,
}) {
// 获得每个通道的值
const geometries = geometriesOptions.map(initialize);
const channels = geometries.map((d) => d.channels);
// 推断 scales 和 guides
const scaleDescriptors = inferScales(channels, scalesOptions);
const guidesDescriptors = inferGuides(scaleDescriptors, { x, y, paddingLeft }, guidesOptions);
// 生成 scales 和 guides
const scales = map(scaleDescriptors, create);
const guides = map(guidesDescriptors, create);
// 生成坐标系
const transforms = inferCoordinates(coordinateOptions).map(create);
const coordinate = createCoordinate({
x: x + paddingLeft,
y: y + paddingTop,
width: width - paddingLeft - paddingRight,
height: height - paddingTop - paddingBottom,
transforms,
});
// 绘制辅助组件
for (const [key, guide] of Object.entries(guides)) {
const scale = scales[key];
guide(renderer, scale, coordinate);
}
// 绘制几何元素
for (const { index, geometry, channels, styles } of geometries) {
const values = applyScales(channels, scales);
geometry(renderer, index, scales, values, styles, coordinate);
}
}
那么接下来我们就一起来看看 initialize
和 inferScales
这两个函数。
initialize
initialize
主要流程代码如下,具体的实现可以参考注释。
javascript
// src/plot/geometry.js
import { compose, indexOf } from '../utils';
import { inferEncodings, valueOf } from './encoding';
import { create } from './create';
export function initialize({
data,
type,
encodings: E = {},
statistics: statisticsOptions = [],
transforms: transformsOptions = [],
styles,
}) {
// 执行 transform
// 把所有的 transform 都合成一个函数
const transform = compose(...transformsOptions.map(create));
const transformedData = transform(data);
const index = indexOf(transformedData);
// 执行 valueOf
// 从表格数据里面提取各个通道的值
const encodings = inferEncodings(type, transformedData, E);
const constants = {};
const values = {};
for (const [key, e] of Object.entries(encodings)) {
if (e) {
const { type, value } = e;
if (type === 'constant') constants[key] = value;
else values[key] = valueOf(transformedData, e);
}
}
// 执行 statistics
// 把所有的 statistics 都合成一个函数
const statistic = compose(...statisticsOptions.map(create));
const { values: transformedValues, index: I } = statistic({ index, values });
// 创建通道
const geometry = create({ type });
const channels = {};
for (const [key, channel] of Object.entries(geometry.channels())) {
const values = transformedValues[key];
const { optional } = channel;
if (values) {
channels[key] = createChannel(channel, values, encodings[key]);
} else if (!optional) {
throw new Error(`Missing values for channel:${key}`);
}
}
// 返回处理好数据
return { index: I, geometry, channels, styles: { ...styles, ...constants } };
}
其中比较关键的函数之一是 inferEncodings
这个函数,这个函数一方面会推断出我们编码的种类,一方面会补全我们的编码信息。下面我们将通过两个例子来说明。
首先我们来看看对编码种类的推断。编码本质上也是一个函数,从数据里面提取一列数据。在 Sparrow 里面的编码有三种类型:
- field:从数据中提取对应字段的值。
- transform:对数据的每一条数据进行转换获得一列值。
- value:返回一个常量数组。
javascript
// src/plot/encoding
export function valueOf(data, { type, value }) {
if (type === 'transform') return data.map(value); // transform encoding
if (type === 'value') return data.map(() => value); // value encoding
return data.map((d) => d[value]); // field encoding
}
具体参考下面这个例子,最后的效果如下图。
javascript
const sports = [
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
];
const options = {
type: 'interval',
data: sports,
encodings: {
x: 'genre', // field encoding
y: d => d.sold * 2, // transform encoding
fill: 'steelblue' // value encoding
},
}
具体的推断方法可以查看这里的 inferType
函数。
接下来我们来看看补全编码信息。在上面绘制条形图的时候,我们对图表的描述如下:
javascript
const options = {
type: 'interval',
data: sports,
encodings: {
x: 'genre',
y: 'sold',
},
}
可以发现在描述中我们是希望通过一个 interval 去可视化数据,并且指定了 interval 的 x 和 y 通道,但是 interval 的 y1 通道却没有指定!这个时候我们就需将这个 y1 通道的编码信息推断出来,最后的结果等于下面的图表描述:
javascript
const options = {
type: 'interval',
data: sports,
encodings: {
x: 'genre',
y: 'sold',
y1: 0, // 推断出来 y1 为 0
},
}
不同的几何图形有不同的推断规则,具体可以查看这里的 inferEncodings
函数。
inferScales
了解了 initialize
函数,我们接下来看看 inferScales
,这个函数可以说是整个渲染流程的灵魂。因为通过前面的学习我们了解到:可视化就是一个数据到图形的过程,而从数据属性到视觉属性需要比例尺去映射。
创建比例尺是一个比较难以理解和麻烦的过程,是使用 D3 等底层可视化组件的过程中需要考虑的问题。但是对于上层可视化框架来说,这部分是要自动完成的的。
而比例尺的创建无非就三个步骤:
- 确定比例尺类型
- 确定值域
- 确定定义域
具体的实现如下:
javascript
// src/plot/scale.js
import { firstOf, group, lastOf, map, defined } from '../utils';
import { interpolateColor, interpolateNumber } from '../scale';
import { categoricalColors, ordinalColors } from './theme';
export function inferScales(channels, options) {
const scaleChannels = group(channels.flatMap(Object.entries), ([name]) => scaleName(name));
const scales = {};
for (const [name, channels] of scaleChannels) {
const channel = mergeChannels(name, channels);
const o = options[name] || {};
const type = inferScaleType(channel, o); // 推断种类
scales[name] = {
...o,
...inferScaleOptions(type, channel, o),
domain: inferScaleDomain(type, channel, o), // 推断定义域
range: inferScaleRange(type, channel, o), // 推断值域
label: inferScaleLabel(type, channel, o),
type,
};
}
return scales;
}
推断比例尺最核心的就是推断比例尺的类型,这里参考 [Observable Plot](https://github.com/observablehq/plot/blob/main/src/scales.j s) 里面的推断方法,具体的实现如下。
javascript
// src/plot/scale.js
function inferScaleType(channel, options) {
const { name, scale, values } = channel; // 当前通道信息
const { type, domain, range } = options; // options.scales 里面的配置
// 如果通道本身有默认的 scale 种类就是返回当前的种类
// 比如 interval 的 x 的 scale 就是 band
if (scale) return scale;
// 如果用户在配置中声明了 type 就返回当前 type
// 比如 scales: { type: log }
if (type) return type;
// 如果配置中的 range 或者 domain 的长度大于了 2 就说明是离散比例尺
// 比如 scales: {fill: {range: ['red', 'yellow', 'green']}}
if ((domain || range || []).length > 2) return asOrdinalType(name);
// 根据配置中 domain 的数据类型决定 scale 的种类
if (domain !== undefined) {
if (isOrdinal(domain)) return asOrdinalType(name);
if (isTemporal(domain)) return 'time';
return 'linear';
}
// 根据 channel 对应的 values 决定 scale 的种类
if (isOrdinal(values)) return asOrdinalType(name);
if (isTemporal(values)) return 'time';
if (isUnique(values)) return 'identity';
return 'linear';
}
function asOrdinalType(name) {
if (isPosition(name)) return 'dot'; // 就是 point 比例尺
return 'ordinal';
}
function isPosition(name) {
return name === 'x' || name === 'y';
}
function isOrdinal(values) {
return values.some((v) => {
const type = typeof v;
return type === 'string' || type === 'value';
});
}
function isTemporal(values) {
return values.some((v) => v instanceof Date);
}
function isUnique(values) {
return Array.from(new Set(values)).length === 1;
}
本章的渲染流程比较重要的代码就在这里介绍完了,完整的代码可以在这里浏览,同样也可以通过这里的测试代码来验证代码的正确性。
小结
到目前为止,我们的 Sparrow 就全部开发完成了,没有借助任何依赖,不到 2000 行代码,可以绘制出平时使用的 80% 的图表(具体的图表可以参考这里的测试代码),是不是很有成就感?(发布我们的图表库到 NPM 可以参考这篇文章)
在实战部分,我们从渲染引擎开始,到一个个低级可视化绘制模块,最后再到本章的 Plot 模块的开发。这个过程我们不仅了解了更多可视化概念,这了解了一些编程方面的知识(比如函数式编程等)。
实战完了接下来就进入我们的分析环节,看看用我们的 Sparrow 能否回答之前提出的问题!