@antv/scale v0.4.0 源码解读,没想到你是这样的 scale!

avatar

G2Plot 最初诞生于阿里经济体 BI 产品真实场景的业务诉求,基于 G2 图形语法,一图一做扩展常见的业务图表。

概述

比例尺(scale) 是数据可视化中很重要的一部分,因为它负责将数据的特征(范围,变化趋势,种类...)映射为视觉元素的属性(大小,颜色,形状...),比如将数据的数值维度映射为散点图里每一个点的位置,又或者将分类数据映射为条形图中每一个条的颜色。

116353528-85644a80-a829-11eb-85e4-3463a29000a9.png

但是 @antv/scale 作为国内最流行的可视化框架之一 G2 的核心依赖,却在一些方面表现的不尽如意:

  • 存在 bug:G2 的一些 bug 都是 @antv/scale 造成的,比如相关的一些 issues
  • 功能不够强大:一方面是提供的比例尺的种类不够丰富,也不支持分段映射;另一方面是性能方面不够优秀,这在大数据场景下是不可接受的。
  • 不能做到社区可用: @antv/scale 最开始是从 G2 中抽离出来的,所以一些 API 都强依赖 G2,比如可以配置坐标轴的标注,这对不使用 G2 的项目是很不友好的。同时它的文档非常的不全面,比如很多比例尺的使用方法和使用场景都没有提到,这导致它的上手会有一定的难度。

所以在 G2 5.0 升级之前,先将 @antv/scale 升级到了 v0.4.0。代码仓库在这里,这里也有一篇在线 demo 简单可视化了不同比例尺的特点。如果大家觉得还不错的话,欢迎给两者点一个小心心! 68747470733a2f2f67772e616c697061796f626a656374732e636f6d2f6d646e2f726d735f3430303532652f616674732f696d672f412a55736732533638354a516b414141414141414141414141414152516e4151.jpeg

接下来这篇文章会介绍 @antv/scale 的一些核心功能和所用到的技术,通过了解比例尺背后的实现方式,可以更加了解它们的特点,应用场景,从而解决使用中的一些问题。

设计目标

上面在描述比例尺中提到了很关键的一个词:“映射”,自然而然可以想到“函数”。确实,从某种意义上来讲,所有的比例尺都可以近似的标识为 y=f(x)y=f(x)。有人可能会觉得这还不简单,直接像下面一样写一个多参数的函数不就搞定了吗?

function map(x, a, b, c, d, ...rest) {
  // ...
}

事情当然不会那么简单,毕竟优秀的算法除了考虑正确性之外,还需要考虑易用性和性能

上面的代码固然是是可以得到期望的结果,但是一方面调用函数的时候每次都要传入一系列相同参数,这无疑是很不好的用户体验。另一方面,在计算过程中有很多计算结果是可以重复使用的,每次都计算一次在大数据的场景下是很影响性能的。

所以我们希望最后的 scale 具有以下的特点:

  • 参数相同的映射,参数只要需要传入一次。
  • 参数更新后,根据最新的参数计算和缓存一些内部状态。
  • 实际映射过程中直接使用一些计算结果。

根据上面的设计目标不难得到下面的代码。

class BaseScale {
  
  constructor(options) {
    this.update();
  }
 
  // 实际 map 过程
  map(x) {/* ... */}
  
  // map 的逆运算
  invert(x) {/* ... */}
  
  // 更新内部状态
  rescale() {/* ... */}
  
  // 更新配置
  update(options) {
    this.options = options;
    this.rescale();
  }
  
}

而对于不同的比例尺来说有以下的不同:

  • 参数(配置)不同
  • 缓存的内部状态不同
  • map 和 invert 的逻辑不同

不难发现,这是一个典型使用继承的场景,所以的比例尺都可以继承于上面的 BaseScale,然后重写 mapinvertrescale 方法即可。下面的一张图展现了 @antv/scale 支持的比例尺和它们的继承关系。

截屏2021-06-06 上午11.59.32.png 分析下来,每一个例尺的使用方式如下,下面用 Linear 比例尺举例:

import { Linear } from '@antv/scale';

const x = new Linear({
  domain: [0, 10],
  range: [0, 100]
});

x.map(5); // 50
x.invert(50); // 5

x.update({
  domain: [0, 20],
  range: [0, 2]
});

x.map(5); // 0.5
x.invert(0.5); // 5

接下来我们就来看看上图中不同种类的比例尺的一些特点和技术难点。

连续比例尺

连续比例尺(Continuous )很常用的一种比例尺,它的特点是定义域(domain)和值域(range)都是连续的,并且输入和输出都可以用一个函数来表示:y=af(x)+by= a * f(x) + b。不同的连续比例尺对应的 f(x)f(x)是不一样的,具体关系如下:

  • Linear:f(x)=xf(x) = x
  • Log:f(x)=log(x)f(x) = log(x)
  • Pow:f(x)=xnf(x) = x ^ n
  • Sqrt:f(x)=x0.5f(x) = x ^ {0.5}
  • Time:f(x)=toTimeStamp(x)f(x) = toTimeStamp(x)

这时大家可能会觉得一行代码就可以搞定这些比例尺了,下面用 Linear 比例尺举例子。

class Linear extends BaseScale {
  /* ... */
  map(x) {
    return this.options.a * x + this.options.b;
  }
}

事情当然也不会这么简单,因为在可视化的场景下,我们往往不知道参数 ab的值,我们只是知道 domain 和 range 的值。 其实这也不难理解,比如在条形图中,我们希望条的高度和数据线性相关,我们知道的只有数据的范围(domain)和条绘制区域的高度(range)。

所以我们需要另外一种方法求解这个函数的方法:归一化(normalize)和线性插值(interpolate),具体的实现如下面的代码。

class Linear extends BaseScale {
  /* ... */
  map(x) {
    const { domain, range } = this.options;
    const t = x / (domain[1] - domain[0]); // normalize
    return range[0] * (1 - t) + range[1] * t;// interpolate
  }
}

上面的代码看似没有问题,但是如果比例尺有额外的能力,比如:

  • 是否可以接受 domain 和 range 是逆序的([5, 2]、[6, 3])
  • 是否在限制输出在值域内
  • 是否需要对输出进行四舍五入

这个时候 map 的过程就会用大量的条件判断的逻辑,一方面是会让 map 的流程不清楚(降低代码可读性),一方面是会影响性能(特别是数据大的场景),所以接下来我们将会用函数合成柯里化的方式来优化上面的代码。

函数合成和柯里化

函数合成(function composition) 是函数式编程的一个重要概念,顾名思义就是将多个函数合成一个函数。

const compose2 = (f, g) => x => f(g(x));
const add = x => x + 1;
const time = x => x * 4;
const map = compose2(add, time);

map(1); // 5
map(2); // 9

下面我们将函数合成的数量从 2 个变成大于等于 2。

function compose(fn, ...rest) {
  return fn.reduce((total, cur) => x => total(cur(x)), fn);
}

不难发现,合成的函数要求输入都只有一个参数,那如果这些函数都是多参数(如下)的函数又该怎么办?

const add = (x, n) => x + n;
const time = (x, n) => x * n;

这个时候就要用到函数柯里化了。柯里化(currying) 指的是将一个多参数的函数拆分成一系列函数,每个拆分后的函数都只接受一个参数(unary)。这样处理后的函数就可以直接合成了;

const createAdd = (x, n) => x => x + n;
const createTime = (x, n) => x => x * n;
const add = createAdd(1);
const createTime = createTime(4);
const map = compose(add, time);

map(1); // 5
map(2); // 9

而连续比例尺 map 函数的优化就用了这种办法,首先通过函数柯里化的方式减少参数,并且消除在 map 过程中的和输入 x 无关的条件判断,然后再将这些不同阶段的函数合成一个最终输出的 output 函数。最后代码可以写成下面的形式。

function createNormalize(a, b) {
  if (a > b) t = a, a = b, b = t;
  return x => x / (b - a);
}

function createInterpolate(a, b) {
  if (a > b) t = a, a = b, b = t;
  return x => a * (1 - x) + b * x;
}

function compose(fn, ...rest) {
  return fn.reduce((total, cur) => x => total(cur(x)), fn);
}

class Linear extends BaseScale {
  /* ... */
  map(x) {
    return this.output(x);
  }
  
  rescale() {
    const { domian, range } = this.options;
    const normalize = createNormalize(domain[0], domain[1]);
    const interpolate = createInterpolate(range[0], range[1]);
    this.output = compose(interpolate, normalize);
  }
}

当然完整代码和这个肯定有所区别,因为 map 过程中其实不只有归一化和线性插值两个阶段,但是对于其他阶段我们也可以通过相同的手段把它合成入最终的函数。比如上面提到的限制输出在值域内。

/* ... */

function createClamp(a, b, shouldClamp) {
  if (a > b) t = a, a = b, b = t;
  return shouldClamp ? (x) => Math.min(Math.max(a, x), b) : x => x;
}

class Linear extends BaseScale {
  rescale() {
    const { domian, range, clamp: shouldClamp } = this.options;
    /* ... */
    const clamp = createClamp(domain[0], domain[1], shouldClamp);
    this.output = compose(interpolate, normalize, clamp);
  }
}

分段映射

分段映射是 @antv/scale 升级后新增的功能,就是可以指定 domain 的某一段映射为 range 的某一段,具体的使用方式如下。

const x = new Linear({
  domain: [0, 10, 100],
  range: [0, 50, 59],
});

x.map(5); // 25
x.map(11); // 51

这个功能的实现相对复杂一点,简单来说就是在 rescale 的时候给每一段都生成一个 normalize 和 interpolate 的函数并用数组保存下来,然后在 map 阶段先执行二分搜索到指定的段的 index,最后使用相应的 normalize 和 interpolate 函数。简化后的核心代码如下。

function bisect(array, x, lo, hi){
  let i = lo || 0;
  let j = hi || array.length;
  while (i < j) {
    const mid = Math.floor((i + j) / 2);
    if (array[mid] > x) {
      j = mid;
    } else {
      i = mid + 1;
    }
  }
  return i;
}

class Linear extends BaseScale {
  rescale() {
    const { domain, range } = this.options;
    const len = Math.min(domain.length, range.length) - 1;
    const normalizeList = new Array(len);
    const interpolateList = new Array(len);
    
    // 给每一段都生成 normalize 和 interpolate
    for (let i = 0; i < len; i += 1) {
      normalizeList[i] = createNormalize(domain[i], range[i + 1]);
      interpolateList[i] = createInterpolate(domain[i], range[i + 1]);
    }
    
    this.output = (x) =>  {
      // 二分查找到对应的段
      // bisect(array, value, lo, hi)
      // 在 array[lo, hi) 中找 value,返回最右边一个匹配值后面的索引 i
      const i = bisect(domain, x, 1, len) - 1;
      const normalize = normalizeList[i];
      const interpolate = interpolateList[i];
      return compose(interpolate, normalize)(x);
    };
  }
}

tickMethod 和 nice

对于连续比例尺来说,还有相当重要的一个部分:nice 和 tickMethod(基本的使用方式如下),它们主要用于生成绘制坐标轴的刻度(ticks)。这两者是比例尺让人不理解和容易出 bug 的地方。

import { Linear } from '@antv/scale';

const options = {
  domain: [0.1, 9.9],
  range: [0, 100],
  nice: true
};
const x = new Linear(options);

x.map(2); // 20
x.invert(20); // 2
x.getTicks(); // [0, 2.5, 5, 7.5, 10]
x.options.doamin; // [0, 10]

其实从上面描述来看比例尺就应该负责纯纯的数学运算,不应该包括视觉相关的东西,但是因为坐标轴刻度的生成和比例尺是强相关的,所以把它们也加入了比例尺中。

生成指定数量且分布均匀的 ticks 并不难,最简单的方法实现如下。

function ticks(start, stop, count) {
  const step = (stop - start) / count;
  const values = new Array(count);
  for(let i = start; i < stop; i++) {
    values[i] = start + i * step;
  }
  return values;
}

上面的方法生成的 ticks 存在一个最大的问题:可读性可能不强,它们中间可能存在小数,也可能存在两个刻度之间的距离(step)不够合理。其实通过分析不难得出,只要 step 合理,其实生成的 ticks 就可读性很强。 ​

对于非时间比例尺之外的连续比例尺来说,就是生成可以表示为 a10n(a=1,2,5,10)a * 10^n (a = 1, 2, 5, 10) 的 step,而对于时间比例尺来说,就是生成以下的时间间隔: ​

  • 1, 5, 15 和 30秒.
  • 1, 5, 15 和 30分钟.
  • 1, 3, 6 和 12-小时.
  • 1 和 2天.
  • 1周.
  • 1 和 3月.
  • 1年.

因为两者本质上的思路是差不多的,所以这里就用普通的连续比例尺生成 step 的函数来举例。这里的方法是直接使用 d3-scale 中的 tickStep,下面对其进行简单的介绍。 ​

大概的思路就是根据指定的 count 生成一个候选的 step,接下来计算出 中 error 的值,之后新的 step 从1,2,5,10中选择一个作为上面表达式中的 a,使得误差最小。具体的实现代码如下。

const e10 = Math.sqrt(50);
const e5 = Math.sqrt(10);
const e2 = Math.sqrt(2);

// 稍微简化代码,保证 stop >= start
export function tickStep(start, stop, count) {
  const step0 = Math.abs(stop - start) / Math.max(0, count);
  let step1 = 10 ** Math.floor(Math.log(step0) / Math.LN10);
  const error = step0 / step1;
  if (error >= e10) step1 *= 10;
  else if (error >= e5) step1 *= 5;
  else if (error >= e2) step1 *= 2;
  return step1;
}

使用了这样生成 step 的 tickMethod 就可以保证在 count 的数量小于定义域的范围的情况下生成的 ticks 都是可读性很强的整数。这里需要着重强调的是:生成的 ticks 的数量和指定的 count 不一定一致,通过上面的算法不难看出为了可读性必须要牺牲这一点。 ​

当然在某些情况下生成 1.5 这样的小数也是可以接受的,同时是否需要生成的 ticks 在 domain 的范围内也是可以视情况而定的,所以除了以上 d3-scale 中生成 ticks 方法之外,@antv/scale 也内置别的计算 ticks 的方法:r-prettywilkinson-extended 方法,甚至可以让用户自定义 tickMethod,具体的使用方式见文档。 ​

而 nice 的作用在我理解一方面是让 domain 变得可读性更强,另一方面是为了生成可读性更强的刻度,思路和 tickMethod 相似。

离散比例尺

介绍完连续比例尺,我们来看看离散比例尺,离散比例尺的最大的一个特点就是它的 domain 是离散的,其中最典型的是就是序数比例尺(Ordinal)。

序数数据是可以排序的分类数据,比如衣服的尺码:s、m、l、xl、xll,当序数比例尺中的所有数据都一样大的情况的时候,序数比例尺其实就变成了分类比例尺,期望的使用的方法如下。

import { Oridnal as Category } from '@antv/scale';

const color = new Category({
  domain: ['a', 'b', 'c'],
  range: ['red', 'yellow', 'blue'];
});

color.map('a'); // 'red'
color.map('b'); // 'yellow'
color.map('c'); // 'blue'

IndexMap

和前面的情况一样,这里似乎可以也可以一行代码解决问题。

class Ordinal extends BaseScale {
  map(x) {
    return this.options.range[this.options.domain.indexOf(x)];
  }
}

但是事情当然也不会那么简单。上面的这种写法最坏情况下每一次都要遍历 domain 去获得相应的 index,如果 domain 的规模为 n 的话,那么这个算法的时间复杂度是 O(n),最多只能算一个可行解。 ​

所以我们需要提升该算法的效率。一种很好的办法就是维护一个 IndexMap,去记录 domain 每一个元素的 index,这样每次获得 index 的时间复杂度为 O(1),是一个高效解。简化版本的实现如下。

class Ordinal {
  
  map(x) {
    const index =  this.indexMap.get(x)
    return this.options.range[index];
  }
  
  rescale() {
    const { domain } = this.options;
    this.indexMap = new Map();
    for(let i = 0; i < domain.length; i++) {
      this.indexMap.set(domain[i],);
    }
  }
}

比较器

不过序数比例尺还有一个很关键的功能需要实现:对 domain 进行排序,毕竟真正的序数数据是有大小关系的。 ​

很典型的一个例子就是在某些情况我们需要把时序数据看成离散的,但它们又是可以排序的,所以这里就需要可以定义比较器了,而期望的使用方法如下。

import { Oridnal } from '@antv/scale';

const color = new Oridnal({
  domain: ['2021-06-03', '2021-06-01', '2021-06-02'],
  range: ['red', 'yellow', 'blue'],
  compare: (a, b) => a.getTime() - b.getTime() // 传入一个比较器
});

color.map('2021-06-01'); // 'red'
color.map('2021-06-02'); // 'yellow'
color.map('2021-06-03'); // 'blue'

当然这个功能的实现相对简单,就是根据排序完成的 domain 去生成 IndexMap,简单的实现如下:

class Ordinal extends BaseScale {
  rescale() {
    const { domain, compare } = this.options;
    const sortedDomain = compare ? [...domain].sort(compare) : domain;
    this.indexMap = new Map();
    for(let i = 0; i < sortedDomain.length; i++) {
      this.indexMap.set(sortedDomain[i], );
    }
  }
}

离散比例尺家族中还有两种比例尺:Band 和 Point,它们使用的典型场景就是确定条形图的每一个条的位置。它们和 Ordinal 的区别就是它们的 range 是连续的,毕竟是在一个连续的范围内绘制条形图。

但是可以注意到的是:条形图的条的位置是等差数列,所以我们完全可以按照某种方式将连续的值域离散化,然后直接按照 Ordinal 的映射方式将定义域映射到生成的这个新的离散的值域。具体的细节就不在这里赘述了,感兴趣可以去看文档

分布比例尺

最后来到了分布比例尺,分布比例尺主要用于分析数据的分布情况,它们的值域都是离散的。在映射过程,首先会用指定的阈值的对定义域进行分段,然后去寻找输入所在段的索引,返回值域对应索引的元素。而根据对定义域分段的标准不同(阈值的不同),可以产生下面三个比例尺:

  • Threshold:定义域是连续的,直接指定定义域分段的阈值。
  • Quantize:定义域是连续的,根据定义域的范围和值域的数量确定阈值,这些阈值将定义域划分为范围相同的几个段。
  • Quantile:定义域是离散的,根据定义域划尽量确定阈值,使得为划分后的段有相同数量的数据的阈值。

下面用最基础的 Threshold 比例尺举例,期望的使用方法如下:

import { Threshold } from '@antv/scale';

const x = new Threshold({
  // 通过 domain 指定 thresholds
  // 将 domain 分成了三段:[-Infinity, 1 / 3)、[1 / 3, 2 / 3)、[2 / 3, Infinity)
  domain: [1 / 3, 2 / 3],
  range: ['a', 'b', 'c'],
});

x.map(0); // 'a': [-Infinity, 1 / 3) -> 'a'
x.map(0.4)).toBe('b'); // 'b': [1 / 3, 2 / 3) -> 'b'
x.map(0.8)).toBe('c'); // 'c': [2 / 3, Infinity) -> 'c

实现的思路也很简单,对于每一个输入的 x,首先通过二分查找找到它在那一段的 index,然后返回对应的 range 的那一段。

function bisect(array, x, lo, hi){
  let i = lo || 0;
  let j = hi || array.length;
  while (i < j) {
    const mid = Math.floor((i + j) / 2);
    if (array[mid] > x) {
      j = mid;
    } else {
      i = mid + 1;
    }
  }
  return i;
}

class Threshold extends BaseScale {
  
  map(x) {
    const index = bisect(this.thresholds, x, 0, this.n);
    return this.options.range[index];
  }
 
  rescale() {
    const { domain, range } = this.options;
    this.n = Math.min(domain.length, range.length - 1);
    this.thresholds = domain; // 这里的定义域就是指定的 thresholds
  }
  
}

而对于 Qunatize 和 Quantile 比例尺来说,只要重写 rescale 改变 this.n 和 this.thresholds 即可,同样具体的计算方法就不在这里赘述了,同样感兴趣可以去看文档

总结

@antv/scale 所有比例尺的核心实现就大概这么多啦,其实不难发现比例尺本身的功能并不复杂,但是如果加上性能的考量就不是那么简单了。通过上面的分析,可以提炼出在大数据情景下的两种提升性能的思路: ​

  • 缓存中间状态,减少相同计算的开销。
  • 通过函数合成和柯里化的方式减少运算过程中的条件判断,有利于编译器优化代码。

当然函数合成除了可以在性能优化方面起作用,还可以大大的增加代码的优雅程度和可读性,在 @antv/coord 中可以说是体现得更加明显。

最后,必须要强烈感谢一下 d3-scale,@antv/scale v0.4.0 从 API 的设计和具体的实现很多都是参考了它,并且一些工具函数也是直接使用的是它内部的代码。d3-scale 在我看来各方面都很完美,唯一的缺点可能就是代码写得过于诗意导致可读性不是很强,毕竟白话文谁都能看懂,但是只有站的足够高才能读懂诗篇

最后的最后,去给 @antv/scale 点个 star 吧!