图标库

渲染引擎

原因

  • 管理图元:使用渲染引擎能够更轻松的绘制并管理图形元素。
  • 提供完善的动画与事件机制:原生语法绘制动画相对比较麻烦。
  • 性能优化:渲染引擎基于底层渲染器的特性进行了大量优化工作,如脏矩阵渲染、分层渲染等,能够取得更好的渲染性能。使得开发者能够专注于视图的构建。
  • 多个渲染器之间任意切换:如果有同时在这两种渲染器中进行绘制的需求,需要针对不同的渲染器进行单独开发,提高工作量的同时也难以保证其一致性。使用渲染引擎绘制时只需要指定所需的渲染器即可完成切换。

功能设计

绘制基本图形 : 支持 rectcirclelinepathtextring 这几种基本图形的绘制。

进行坐标系变换: 支持 translatescalerotate 这三种变换,同时可以使用类似 Canvas2D 的 save 和 restore 去管理坐标系变换的状态。

测试相关

本项目采用vitest单元测试, 具体操作可参考官网快速起步 | 指南 | Vitest

同时我们在写代码的过程中不会完全遵循 airbnb-base 的规范,所以需要修改 .eslintrc.js 如下,关闭一些规则的校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// .eslintrc.config.js
rules: {
// 关闭 eslint 的如下功能
'import/prefer-default-export': 0,
'no-use-before-define': 0,
'no-shadow': 0,
'no-restricted-syntax': 0,
'no-return-assign': 0,
'no-param-reassign': 0,
'no-sequences': 0,
'no-loop-func': 0,
'no-nested-ternary': 0
}

比例尺

可视化中的比例尺, 是用来度量数据属性的, 将数据抽象的属性映射为一个视觉属性

这决定了我们如何理解图形的颜色, 大小, 形状和位置. 将这些数据以什么样的形式表现出来

比例尺本质上是一个函数,会将一个值(变量)从一个特定的范围(定义域)映射到另一个特定的范围(值域)。定义域(Domain)是由数据的属性决定,值域(Range)是由图形的视觉属性决定。根据定义域和值域的不同,我们需要选择不同的比例尺。

Identity

“恒等映射”, 将输入原封不动的返回

连续比例尺

连续比例尺主要用于数值属性的映射,它的特点是定义域和值域都是连续的,它们之间的关系可以表示为:y = a * f(x) + b

对于所有的连续比例尺来说,除了拥有映射功能之外,还有一个很重要的功能:生成坐标轴需要的坐标刻度。在理解可视化图表的过程中,坐标轴往往是一个很好的辅助,因为它能给我们提供更多辅助信息。

Linear

(1) 线性映射

Linear 比例尺是支持线性映射的比例尺,它的表达式 y = a * f(x) + b 中的 f(x) 应该为 f(x) = x

Linear 比例尺常常用于视觉元素的布局,比如在做散点图的时候,可以用 Linear 比例尺来完成对点的布局,比如将数据的某个数值属性映射为点的 x 坐标,将数据的另一个个数值属性映射为点的 y 坐标

输入在定义域里到两端的比例,应该和输出在值域到两端的比例相同
它期望的使用方式如下:

1
2
3
4
5
6
7
const scale = createLinear({
domain: [0, 1], // 输入的范围是 [0, 1]
range: [0, 10], // 输出的范围是 [0, 10]
})

scale(0.2); // 2
scale(0.5); // 5

(2)ticks 和 nice

生成坐标刻度的方法

通过数学方法 选取合适的刻度间隔, 同时减少误差

给定一个范围(minmax)和一个刻度数量(count),我们希望计算出 最终的刻度间隔step1),这个间隔满足以下条件:

  1. step1 形式是 10^n * b,其中 b1, 2, 5 中的一个,n 是整数。这种形式保证了间隔是“对称的”,符合数轴的常见标度。

  2. step1 和初始估算的 step0 的误差尽量小step0 是直接根据 (max - min) / count 得到的目标间隔,但这个值不一定是符合 10^n * b 形式的,我们需要调整它。

代码逐行分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// step0 是生成指定数量的刻度的间隔
// step1 是最后生成的刻度的间隔
// 我们希望 step1 满足两个条件:
// 1. step1 = 10 ^ n * b (其中 b=1,2,5)
// 2. step0 和 step1 的误差尽量的小
export function tickStep(min, max, count) {
const e10 = Math.sqrt(50); // 7.07
const e5 = Math.sqrt(10); // 3.16
const e2 = Math.sqrt(2); // 1.41

// 获得目标间隔 step0,设 step0 = 10 ^ m
const step0 = Math.abs(max - min) / Math.max(0, count);
// 获得 step1 的初始值 = 10 ^ n < step0,其中 n 为满足条件的最大整数
let step1 = 10 ** Math.floor(Math.log(step0) / Math.LN10);
// 计算 step1 和 step0 的误差,error = 10 ^ m / 10 ^ n = 10 ^ (m - n)
const error = step0 / step1;
// 根据当前的误差改变 step1 的值,从而减少误差
// 1. 当 m - n >= 0.85 = log(e10) 的时候,step1 * 10
// 可以减少log(10) = 1 的误差
if (error >= e10) step1 *= 10;
// 2. 当 0.85 > m - n >= 0.5 = log(e5) 的时候,step1 * 5
// 可以减少 log(5) = 0.7 的误差
else if (error >= e5) step1 *= 5;
// 3. 当 0.5 > m - n >= 0.15 = log(e2) 的时候,step1 * 2
// 那么可以减少 log(2) = 0.3 的误差
else if (error >= e2) step1 *= 2;
// 4. 当 0.15 > m - n > 0 的时候,step1 * 1
return step1;
}

给定一个范围(min 和 max)和一个刻度数量(count),我们希望计算出 最终的刻度间隔(step1),这个间隔满足以下条件:

step1 形式是 10^n * b,其中 b 是 1, 2, 5 中的一个,n 是整数。这种形式保证了间隔是“对称的”,符合数轴的常见标度。

step1 和初始估算的 step0 的误差尽量小。step0 是直接根据 (max - min) / count 得到的目标间隔,但这个值不一定是符合 10^n * b 形式的,我们需要调整它。

代码逐行分析

1
2
3
4
// 常数 e10, e5, e2 用来定义误差范围,分别对应于 sqrt(50), sqrt(10), sqrt(2)
const e10 = Math.sqrt(50); // 7.07
const e5 = Math.sqrt(10); // 3.16
const e2 = Math.sqrt(2); // 1.41

这三行定义了误差的阈值,分别对应于 误差范围。它们代表了在 step0 和 step1 之间容忍的误差程度。如果误差大于这些值,我们需要调整 step1。

1
2
// 计算 step0:目标的刻度间隔
const step0 = Math.abs(max - min) / Math.max(0, count);

step0 是根据给定的 最小值(min)和最大值(max) 计算出来的初步间隔。具体计算方法是将范围 (max - min) 除以刻度数量 count,从而得到大致的间隔。

1
2
// 计算 step1:找到离 step0 最近的 10 的幂次
let step1 = 10 ** Math.floor(Math.log(step0) / Math.LN10);

这行计算了 step0 的 最接近的 10 的幂次,即找到 step0 所对应的 10^n 的值。这个值是 step0 的初步刻度间隔,但还不能保证符合 10^n * b 的形式。

1
2
// 计算误差:step1 和 step0 之间的比例
const error = step0 / step1;

error 表示 step0 与 step1 的误差,即 step0 和 step1 的比例关系。我们希望 step1 与 step0 的误差尽量小。

// 根据误差调整 step1 的值

1
2
if (error >= e10) step1 *= 10;
如果误差大于或等于 e10(7.07),说明 step1 需要再扩大十倍,以减小误差。
1
2
3
else if (error >= e5) step1 *= 5;

如果误差大于或等于 e5(3.16),但是小于 e10,则乘以 5 来减少误差。
1
2
else if (error >= e2) step1 *= 2;
如果误差大于或等于 e2(1.41),但小于 e5,则乘以 2 来进一步减小误差。
1
2
else if (error >= 0) step1 *= 1;
如果误差非常小(小于 e2),则保持 step1 不变。

代码总结

step0 是初步估算的刻度间隔,通过 (max - min) / count 得到。

step1 是通过 10 的幂次逼近的刻度间隔,然后通过调整乘以 1, 2, 5 或 10 来减小误差,使得 step1 更接近 step0,且符合 10^n * b 的形式。

Time

Linear 比例尺要求定义域都是数字,但是有的时候我们希望定义域是浏览器的时间对象(Date),这个时候就需要用到 Time 比例尺了。

序数类比例尺

如果说连续比例尺复杂数值属性的映射, 那么序数类比例尺就负责序数属性的映射

Oridinal

Oridinal要用于将序数属性映射为同为序数属性的视觉属性,比如颜色,形状等。

实现思路: 首先从定义域中找到输入对应的索引, 然后返回值域中对应索引的元素

Band

Band 比例尺主要用于将离散的序数属性映射为连续的数值属性,往往用于条形图中确定某个条的位置。

比如下面我们将水果的名字映射为下面每个条的位置,其中每一个条使一个 Band,它的宽度为 BandWidth,条之间的间距为 Padding,步长是 BandWidth 和 Padding 之和。

1
2
3
4
5
6
7
8
9
10
const options = {
domain: ["苹果", "香蕉", "梨"],
range: [0, 320], // 上图中 width 的范围
padding: 0.2, // 控制条之间的间距,上图中的 padding
}

const scale = createBand(options);
scale("苹果"); // 20
scale("香蕉"); // 120
scale("梨"); // 220

Point

Point 比例尺是一种特殊的 Band 比例尺,它的 Padding 始终为 1,也就是说它的 BandWidth 始终为 0

分布比例尺

将我们提供的数据分组, 每一组使用一个颜色来编码

Threshold

它的定义域是连续的,并且会被指定的分割值分成不同的组

Quantize

相对于 Threshold 比例尺需要我们指定分割值,Quantize 比例尺会根据数据的范围帮我们选择分割值,从而把定义域分成间隔相同的组。

这出现了一个问题: 当数据的分布有倾斜的时候,会出现几乎所有数据都在一个组,只有一些极端值在自己组的情况

Quantile

和 Quantize 比例尺不同是,Quantile 比例尺采用了另外得到分割值的策略: 根据数据出现的频率分组。

Quantile 是根据数据在整个数据集的排名来分组的,所以会缺少数据绝对大小相关的分布信息。

坐标系

坐标系是将视觉元素的位置属性映射为在画布上的坐标

每一个坐标系都包含两个部分: 画布的位置和大小一系列坐标系变换函数

将一个统计意义上的点, 转换成画布上的点

统计意义上的点是指:点的两个维度(x和y)都被归一化了,都在 [0, 1] 的范围之内。这样在将点在真正绘制到画布上的之前,我们不用考虑它们的绝对大小,只用关心它们相对大小等统计学特征。这些特征在变换过程中都不会丢失。

基本变换

基本变换本质上是一个函数,输入是变换前点的坐标,输入是变换后点的坐标。同时该变换函数有 type 方法返回自己的变换类型(后面会使用到)

平移, 缩放, 反射, 转置

这里就不细讲啦, 直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function transform(type, transformer) {
transformer.type = () => type
return transformer
}
export function translate(tx = 0, ty = 0) {
return transform('translate', ([px, py]) => [px + tx, py + ty])
}
export function scale(sx = 1, sy = 1) {
return transform('scale', ([px, py]) => [px * sx, py * sy])
}
export function reflect() {
return transform('reflect', scale(-1, -1))
}
export function reflectX() {
return transform('reflectX', scale(-1, 1))
}
export function reflectY() {
return transform('reflectY', scale(1, -1))
}

export function transpose() {
return transform('transpose', ([px, py]) => [py, px])
}

极坐标

相同的点在极坐标系下就会被表示为 (raduis, theta)radius 是到极点的距离,theta 是点和极点的连线和极轴的角度。两者可以相互转换。具体参考下面这张图。

1
2
3
4
5
6
7
8
9
export function polar() {
// 这里我们把点的第一个维度作为 theta
// 第二个维度作为 radius
return transform('polar', ([theta, radius]) => {
const x = radius * Math.cos(theta);
const y = radius * Math.sin(theta);
return [x, y];
});
}

坐标系变换

坐标系变换会根据画布的位置和大小, 以及基本变换本身需要的参数去生成一个由基本变换构成的数组. 所以所有的坐标系变换都应该接受两个参数: transformOptionscanvasOptions, 然后返回以一个数组.

笛卡尔坐标系变换

Cooridante 里的笛卡尔坐标系变换是将统计学上的点线性转换成画布上的点。

![[6a43aa4b1c30fdf16bb9927d1d11c18e_MD5.webp]]
使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { cartesian } from './cartesian';

const canvasOptions = {
x: 0,
y: 0,
width: 600,
hieght: 400,
};

// cartesian 不需要 transformOptions
const transforms = cartesian(undefined, canvasOptions);
// 合成一个函数
const map = compose(...transforms);

map([0, 0]); // [0, 0]
map([0.5, 0.5]); // [300, 200]
map([1, 1]); // [600, 400]

但是这里存在一个问题 transformOptions 在定义坐标系的时候需要用户显示指定的,canvasOptions 是在执行坐标系函数的时候被传入的,两者被传入的时间不同。

这个时候就需要延迟函数的执行,只有当 transformOptions 和 canvasOptions 都被传入的时候才执行该函数。这久可以用到我们前面提到的函数柯里化了。柯里化后的 cartesian 就可以如下使用。

1
const transforms = cartesian()(canvasOptions);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/coordinate/cartesian.js

import { curry } from '../utils';
import { scale, translate } from './transforms';

function coordinate(transformOptions, canvasOptions) {
const {
x, y, width, height,
} = canvasOptions;
return [
scale(width, height),
translate(x, y),
];
}

export const cartesian = curry(coordinate);

当然这里使用的 curry 会和之前提到的有一点不一样:当不传入参数的时候,需要等价于传入了 undefined 参数。也就是在使用柯里化后的 cartesian 函数的时候 cartesian() 等价于caresian(undefined)

1
2
3
4
5
6
7
8
9
10
11
12
// src/utils/helper.js

export function curry(fn) {
const arity = fn.length;
return function curried(...args) {
// 如果没有传入参数就把参数列表设置为 [undefined]
const newArgs = args.length === 0 ? [undefined] : args;
if (newArgs.length >= arity) return fn(...newArgs);
return curried.bind(null, ...newArgs);
};
}

极坐标系变换

这里的极坐标系变换和前面的极坐标变换的区别在于:

  • 极点不同:极点从画布左上角变成了画布中心。
  • 大小不同:坐标系构成的圆形会内切画布。
  • 范围不同:可以指定坐标系开始的角度:startAngle 和结束的角度 endAngle。也可以指定内半径 innerRadius 和外半径 outerRadius (范围都是:[0, 1])。

![[218d475eceeab5f02881d7136a491e02_MD5.webp]]

转置坐标变换

![[23776155cdabd681c0e4f872b6b25761_MD5.webp]]

几何图形

数值通道(Magnitude Channel)会我们提供和有多少相关的信息,主要用来编码数值属性,比如下图中的位置(Position)、大小(Size)和倾斜角度(Tilt)都是数值属性;
特征通道(Identity Channel)给我们提供是什么、在哪里相关的信息,主要用来编码分类属性,比如形状(Shape)和颜色(Color)。

几何图形渲染的数据不是一个数组,而是一个对象。这个对象的每一个 key 都是该几何图形的一个通道,对应的 value 是一个数组,数组的每一个元素是数据和该通道绑定的属性的值

创建通道

每一个通道都是一个对象,它拥有的属性如下。

属性名 描述 可选 默认值
name 属性的名字 -
optional values 里面是否需要该属性对应的值 true
scale 需要使用的比例尺

通道一方面可以对我们渲染的数据进行校验,另一方面可以在后面的开发中使用。

几何图形

在代码中的几何图形和比例尺、坐标一样,都是一个函数,它会将处理好的数据转化成屏幕上的像素点,因为我们的渲染器是基于 SVG 的,所以其实是转换成对应的 SVG 元素

对于每一个几何图形,我们需要定义它的通道和渲染函数,并且在渲染之前检查一下是否提供了需要的数据和正确的比例尺。