Sparrow

Sparrow 一个基于原生JS和浏览器SVG构建的可视化底层库,实现了数据可视化的完整流程,涵盖了坐标转换,几何计算,比例尺,浏览引擎,数据处理等核心能力。

工程化方面

代码规范:ESLint 实现代码自动格式化与错误检查
单元测试: Test 使用Jest + Electron用真实浏览器环境测试,结合Babel编译,支持可视化图形渲染验证,确保组件功能稳定
构建打包: 使用Rollup进行多格式打包
版本管理: 通过 Husky + lint-staged + commitlint 实现提交前代码检查与规范化commit 信息
Git提交校验
持续集成CI : 接入了GitHubActions持续集成

实际功能方面

渲染引擎

为了简化原生SVG繁琐的绘画流程,我从零实现了一个轻量级的2D渲染引擎 核心作用是封装底层绘图逻辑
具体实现上,创建了所有SVG元素顶层容器画布节点,返回了这个画布节点和管理子元素的group节点
统一封装了基础图形的绘画逻辑(统一接口 只需要传入不同图形的type和对应的参数)
图形的transform逻辑 (这里需要创建新的group节点 防止旋转到分组中的其他元素)
在实现渲染引擎的时候,为了实现不同风格的渲染引擎,在这里基于rough封装了手绘风格的渲染引擎,作为一个第三方插件能够直接使用,在这个开发过程中接触了npm的发布和上传
这里使用的是作用域库

1
2
3
4
5
6
确保 npm 镜像源是官方源(发布必须用,否则会发布到私有源)
npm config set registry https://registry.npmjs.
npm login
发布新版本(作用域包需加 --access=public,否则默认私有)
npm publish --access=public
如果是更新的话 还可以使用npm version来选择版本

手绘风格的具体实现上, 是调用了roughjs的方法,拿到原来的上下文然后用这个方法对上下文进行拓展得到具有手绘功能的上下文

1
2
3
4
5
6
import rough from 'roughjs'
export function createRoughContext(context) {
const {node} = context;
const rc = rough.svg(node);
return { ...context, rc };
}

比例尺

比例尺方面 实现了数据到图形可识别的视觉属性的映射,是从数据到图形的桥梁,主要实现了下面三个功能:

1.将数据定义域映射到视觉值域

在映射方面,根据定义域的不同封装了三大类型的比例尺
连续型(数值到数值) 序数型(个人理解就是相关映射,定义域和值域可能都不是数值类型 通过索引来映射)分布型(根据数据的区别进行分类映射)
本质上,我们希望定义域和值域在各自坐标轴上占的比例是一样的
所以对数据是做了归一化处理,通过归一后的比例在值域上去插值

2.ticks为坐标轴生成美观可读均匀的刻度

想要使用的基本上是1 2 5 10 所以这里做了做了误差判断,通过对数差计算误差

3.用nice方法优化定义域范围,增加可读性

nice是用来处理定义域数据不够好看的 可读性不够强 所以我们需要根据刻度间隔去调整定义域的范围 使得最小值和最大值都是刻度间隔的整数倍

坐标轴

坐标轴部分负责将比例尺映射后的归一化坐标 [0~1],通过坐标变换,转换成最终画布坐标
坐标轴实现的主要内容:1.将归一化坐标转换成画布坐标2.实现坐标系各种转换效果,进而实现了从笛卡尔坐标系到极坐标系和转置坐标系的转换(通过坐标系的几种基本变换 实际上是对坐标做的转换)
以笛卡尔坐标系转换为极坐标系为例

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { curry } from "../utils/helper";
import { reflectY, scale, translate, polar as polarT } from "./transforms";
function coordinate(transformOptions, canvasOptions) {
const { width, height } = canvasOptions;

const { innerRadius, outerRadius, startAngle, endAngle } = transformOptions;

// 保证最后经过cartesian变化之后是一个圆形

// 需要根据画布宽高去调整`

const aspect = width / height;

const sx = aspect > 1 ? 1 / aspect : 1;

const sy = aspect > 1 ? 1 : aspect;

return [
/* 下面这部分规则的原因是 从笛卡尔坐标到极坐标有几个需要变换的点`

`1. 极坐标的0度方向在笛卡尔坐标的x正方向,而极坐标的角度是逆时针增加的,所以需要做y轴翻转`

`2. 极坐标的角度和半径是有范围的,需要把[0,1]区间映射到指定的[startAngle,endAngle]和[innerRadius,outerRadius]`

`3. 极坐标系是以画布中心为原点的,而笛卡尔坐标系是以左上角为原点的,所以需要平移`

`4. 极坐标系需要内切于画布,所以需要根据画布宽高进行缩放,并且需要移动到画布中心`

*/

// 以画布中心沿着y方向翻转

translate(0, -0.5),

reflectY(),

translate(0, 0.5),

// 调整角度和半径的范围`

scale(endAngle - startAngle, outerRadius - innerRadius),

translate(startAngle, innerRadius),

polarT(),

// 改变大小内切画布

scale(sx, sy),

scale(0.5, 0.5),

// 移动到画布中心

translate(0.5, 0.5),
];
}
export const polar = curry(coordinate);

几何图形

核心绘图层,负责把经过比例尺映射、坐标系转换后的数据,渲染成 SVG 图形(点、线、面、矩形、文字、链接、区域、格子、间隔)
最核心的 几何图形 = 通道 + 渲染

  • 通道是定义我们需要哪些数据 xyrzfillstroke

  • 渲染是根据坐标转换成SVG元素

具体的 这些几何图形都用工厂函数统一创建 每个图形会有单独的render用于创建对应的SVG元素

这里的render其实会调用渲染引擎中的shape来画图 区别是geometry里面的会考虑参数坐标轴等元素

辅助组件

guide辅助组件 = 比例尺的可视化

即对于一个图表 我们需要标识来知道分别代表什么

需要 Guide:

  1. Axis 坐标轴:告诉你 x、y 代表什么、刻度多少 -> 可视化xy比例尺 轴
  2. Legend 图例:告诉你颜色、大小代表什么 -> 可视化colorsize比例尺
  3. Annotation 标注:告诉你重点数据(最大值、平均值等)

坐标系自适应
axisX 和 axisY 会自动根据 isPolar() isTranspose() 切换 4 种形态
笛卡尔坐标系 → 直线轴 + 直网格
极坐标系 → 圆弧轴 + 放射网格 / 同心圆网格

图例坐标轴不会关注坐标系

统计

数据统计变换层statisitc
它的作用是在绘图前预处理数据,修改或生成 x、y、y1 等位置通道,从而实现堆叠、归一化、对称、分箱等功能,

  • 堆叠:把同一个x上的多个y堆叠起来
  • 归一化:把同一组的y都缩放到0~1 变成百分比
  • 对称:让一组图沿中线对称
  • 分箱:把连续数值切分成一段一段的

视图

View视图 即是画布的布局系统
对多视图图标来说 其实有一颗视图树 每一个节点都是其中的一个视图

1
2
3
4
5
6
7
8
9
10
11
12
13
const viewTree = {
type: 'layer',
children: [
{
type: 'row',
children: [{/.../}, {/.../}]
},
{
type: 'interval',
}
]
};

Layer 图层(重叠)
多个图表叠在同一块区域
场景:折线图 + 柱状图、线 + 点、面积 + 线
所有子视图 x/y/宽高 完全一样


Flex 弹性布局(排列)
分两种:

  • row:横向排列
  • col:纵向排列
    作用:像 CSS Flex 一样,把区域切分成几块
    场景:左右对比图、上下多图
    支持:
  • flex 比例
  • padding 间距

Facet 分面(网格 + 数据过滤)
分面是可视化中一个常用的手段,主要用于对数据进行分组,不同组的数据在不同的视图中展示。
作用:

  1. 按数据字段分成网格(如 male/female、skin color)
  2. 每个子视图自动过滤数据
  3. 生成 N × M 个子图表
    场景
  • 分面直方图
  • 分面散点图
  • 分组对比分析
    每个分面视图自带一个 transform 函数:
1
transform(data); // 只返回当前分面对应的数据

渲染流程Plot

最后Plot其实就是将所有模块串起来的总控中心
sparrow最后暴露出来的接口为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
plot({
type: 'interval', // 图形类型:柱/线/点/饼...
data: [...], // 数据
encodings: { // 数据映射:x、y、fill
x: 'genre',
y: 'sold',
fill: 'genre'
},
transforms: [], // 数据预处理
statistics: [], // 统计变换:stack、normalize...
scales: {}, // 比例尺配置
guides: {}, // 坐标轴、图例
coordinates: [], // 坐标系:极坐标、翻转
children: [] // 视图布局
})

常见的函数

  • plot()

入口函数,调度所有流程

  • plotView()

真正绘制单个视图

  • initialize()

执行 transform -> 执行 encoding -> 执行 statistic -> 获取最终通道值

  • inferScales()

整个可视化库最关键的函数

自动推导比例尺:

linear /ordinal/band /time/log

主要过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1. plot()           【总入口】

2. createViews() 【计算布局】

3. plotView() 【绘制单个图表区域】

├─ 4. initialize() 【对每个几何图形:数据处理 → 得到通道值】
│ → transform
│ → encoding (从数据中拿到字段 对应到通道中去)
│ → statistic

├─ 5. inferScales() 【根据通道值 → 自动生成比例尺】

└─ 最后绘制:Guide + Geometry

数据可视化流程

视图 / 布局 → 数据变换 → 编码 → 比例尺 → 统计 → 坐标系 → 几何 → 渲染

这里的编码ecoding就是从数据中拿到字段 然后对应到通道中去

收获

通过这个项目学到了什么?

  1. 首先这个项目让我学到了可视化的一般流程

    即数据通过比例尺映射到视觉通道的过程 将数据映射为视觉元素
    更具体的说 视图 / 布局 → 数据变换 → 编码 → 比例尺 → 统计 → 坐标系 → 几何 → 渲染 的过程
    这种思想与其他成熟的线代图标底层库的实现逻辑几乎完全相同

    2.然后这个项目让我进一步熟练了工厂函数的设计与使用,

​ 项目中的比例尺,统计函数,几何图形,坐标系,辅助组件等核心模块,几乎都采用工厂函数模式创建,通过统一的create()入口,新增逻辑的时候,只需要 增加对应的实现,不需要修改原有代码,符合开放闭合的原则。其中的模块化,可插拔设计,让我对项目架构设计,函数式编程,低耦合扩展有了更深入的实践与理解。

3.对数据的处理能力

​ 在项目中需要处理来源多样、结构不一的原始数据,通过完整的数据处理链路,将杂乱数据转化为可直接绘图的规范格式。主要包括对数据进行清洗、过滤、排序等预处理,通过编码映射完成字段到视觉通道的提取,同时实现堆叠、归一化、分箱等统计变换,还支持按维度分组与分面过滤,能够适配柱状图、饼图、直方图等不同图表的数据要求,具备处理复杂业务数据并完成可视化转换的完整能力。