如何基于数据可视化超大数据量做好图形展示,来学习你还不知道的 LTTB 算法

Largest Triangle Three Buckets,初次看到这几个词,一脸懵,这是个 what?最大的三角形三个桶,三个桶组成一个最大的三角形?桶里是啥,为什么要整三角形?咱是真不懂,好在咱们可以学,就像正在看文章的你一样,还不去点个赞,这么爱学习。

前言

本文基于《Downsampling Time Series for Visual Representation》介绍关于 LTTB 算法的内容,将介绍算法核心,解决的问题,实现具体逻辑及可视化中的应用场景。

以折线图为例,可视化场景中,当 x 轴的数据不断增多,对应 y 轴的数据量增多,体现在图上的折线就会变得越来越复杂,当数量达到一定程度,很难再通过图找到具体的某一个点所表述的真实值是什么,数据变得很拥挤。

5000个数据点绘制图的折线图

image.png

为了能够看到图形的整体,我们就要隐藏一些点,仅展示那些能够代表其他的点,或者是创建能够代表这些点的新数据点,这就是降采样算法解决的问题。

最大三角形三桶算法

世界上没有什么东西是完美的

算法都有自己的局限性,适用的范围,LTTB 算法也是一样,算法中仅保留能够提供重要信息、容易被人们感知到的点,其他的点被忽略。这样带来的好处显而易见,可以看见图表的全貌、加快渲染速度,节省带宽和时间。

采用 LTTB 算法,5000数据点降采样到888个数据点绘制的折线图,几乎完全一致

image.png

算法实现步骤:

  1. 除去首尾两个数据点,把所有的数据点按顺序尽量等分到每个桶。桶是什么?桶是多个数据点组成的数据子集。
  2. 第一个数据点放进第一个桶,最后一个数据点放进最后一个桶,且均只有这一个点。
  3. 遍历所有的桶,从每个桶中找到最合适的一个点,代表这个桶。

桶中的所有点,即有效点,就是降采样后的数据点集合。

重点就在于如何找到可以代表当前桶的那个点,也就是三桶算法的核心设计。

最大三角形面积,是使用的哪三个桶,怎么取数据,先看一张图。

image.png

虚竖线把区分成三部分,分别代表三个桶的数据区域。

从首个桶开始计算,因为首个桶只有第一个数据点,所以 A 就是第一个数据点,已知,不需要计算,是预选点。B 点来自于第二个桶,C 点来自于第三个桶。我们先说点 C 的选择,如果按照每个桶中有100个数据点计算,那么确定出合适的点,就需要100 * 100,共1W次的计算,更合适的做法,是选出一个临时点能够代表其他点,然后再通过100次计算求得合适的 B 点,简化过程。因此规定,C 点为临时点,是平均点,简单可求得。

此时,我们已知预选点 A 和临时点 C,通过遍历 B 桶中的数据点,选取组成的三角形 ABC 面积最大的那个点作为 B 点,来代表 B 桶。

  • 点 A,预选点
  • 点 B,和 ABC 组成的三角形面积最大的点
  • 点 C,C 桶数据点的平均点

然后,按照上述逻辑,继续把 B 点当成第一个点,作为预选点,已知。D 桶(图中未给出)中选取临时点 D 点,从而计算出 C 桶中真正能代表 C 桶的 C 点。

依次循环遍历,完成所有桶的逻辑计算。

书中给出明确的算法逻辑实现源码

Echarts 中的应用场景

在 Eharts 折线图中,有series-line.sampling属性支持 'lttb' 算法的使用。

image.png

我们对echarts源码进行详细的分析。

/**
* Large data down sampling using largest-triangle-three-buckets.
* buckets: An ordered set containing a subinterval of data points.
* 桶:包含数据子区间的有序集。
* @param {string} valueDimension
* @param {number} targetCount
*/
lttbDownSample(
    valueDimension: DimensionIndex,
    rate: number
): DataStore {
    const target = this.clone([valueDimension], true);
    const targetStorage = target._chunks; // 目标数据集,即x 与 y 轴数据
    const dimStore = targetStorage[valueDimension]; // y 轴值数据
    const len = this.count(); // y 轴值的总数量,例如:个数为1630

    let sampledIndex = 0; // 初始采样索引 0

    const frameSize = Math.floor(1 / rate); // x 轴的缩放比例,每次的步长,当前示例为3
    // 当前的真实索引 raw 原始的,注意,这里是索引,不是具体值。当前是0
    let currentRawIndex = this.getRawIndex(0);
    let maxArea; // 最大面积
    let area; // 临时计算面积
    let nextRawIndex; // 下一个原始索引

    // getIndicesCtor 根据 this._rawCount 的大小,判断返回哪种类型
    // Uint32Array or Uint16Array 初始化扇区缓存,初始数据全部是0
    const newIndices = new (getIndicesCtor(this._rawCount))(Math.ceil(len / frameSize) + 2); 

    // First frame use the first data.
    newIndices[sampledIndex++] = currentRawIndex;
     // 去除首尾两个点,步长循环处理
    for (let i = 1; i < len - 1; i += frameSize) {
        // 后一个桶的 x 轴平均值,这里加上 frameSize 是因为起点已经确认
        // 第二个桶开始点,应该与第一个间隔frameSize。
        const nextFrameStart = Math.min(i + frameSize, len - 1);
        const nextFrameEnd = Math.min(i + frameSize * 2, len);
        const avgX = (nextFrameEnd + nextFrameStart) / 2;

        // 后一个桶的 y 的平均点
        let avgY = 0;
        for (let idx = nextFrameStart; idx < nextFrameEnd; idx++) {
            const rawIndex = this.getRawIndex(idx);
            const y = dimStore[rawIndex] as number; // 获取 y 轴真实的数值,data 中给的
            if (isNaN(y)) { // 是非数字,跳过当前循环,开始下一个循环
                continue;
            }
            avgY += y as number; // 累加
        }
        // 值的平均,桶或者点集的总和(排除非法值)除以长度
        avgY /= (nextFrameEnd - nextFrameStart);

        const frameStart = i; // 当前点集 起始索引
        const frameEnd = Math.min(i + frameSize, len); // 当前点集,结束索引

        const pointAX = i - 1; // A 为三个点中的首个点。当前为 0
        const pointAY = dimStore[currentRawIndex] as number; // 首个点的真实 y 值

        maxArea = -1; // 初始最大面积

        // 整体上来看,x 轴为点的位置坐标,为值,y 轴为真实的数值
        nextRawIndex = frameStart;

        for (let idx = frameStart; idx < frameEnd; idx++) {
            const rawIndex = this.getRawIndex(idx);
            const y = dimStore[rawIndex] as number;
            if (isNaN(y)) { // 是非数字,就跳过当前循环
                continue;
            }
            // 构筑直角坐标系,三角形的面积和,等于左侧矩形加上右侧梯形减去右上三角形
            // 整理可得如下公式。
            area = Math.abs((pointAX - avgX) * (y - pointAY)
                - (pointAX - idx) * (avgY - pointAY)
            );
            if (area > maxArea) {
                maxArea = area;
                nextRawIndex = rawIndex; // Next a is this b
            }
        }
        // 找到下一桶的最大面积真实索引,同时 sampleIndex++ 下移一位
        newIndices[sampledIndex++] = nextRawIndex;
        // This a is the next a (chosen b) 对于下一个桶,当前的最佳桶就是上一个桶。
        currentRawIndex = nextRawIndex;
    }

    // First frame use the last data.
    newIndices[sampledIndex++] = this.getRawIndex(len - 1); // 最后一个点
    target._count = sampledIndex; // 总数
    target._indices = newIndices; // 赋值索引集

    target.getRawIndex = this._getRawIdx;
    return target;
}
复制代码

如果想在 echarts 中测试 sampling 的效果,可以点这里

结语

超大数据量的图形可视化是非常有意思的话题,也是性能优化中经常遇到的核心点。书中还有很多算法,这里没有介绍,并结合性能、复杂度、准确度等指标做了明确对比,在绝大多数情况下,LTTB 算法的表现都是最优异的,也就是基于场景的不同,优先考虑 LTTB 算法,不满足需求时,再尝试其他算法。

image.png

参考资料

  1. Downsampling Time Series for Visual Representation
  2. Largest Triangle Three Buckets
  3. 时序数据可视化的降采样算法
  4. 降采样示例
  5. 降采样示例

猜你喜欢

转载自juejin.im/post/7066969789322756132