Vega Voronoi 变换完全指南:基于 vega-voronoi 计算数据点单元路径
2026/9/24 6:19:46 网站建设 项目流程
  • 数据可视化

【免费下载链接】vega

A visualization grammar.

项目地址:https://gitcode.com/gh_mirrors/ve/vega
点击查看免费下载

vega-voronoi是 Vega 生态中专门负责计算 Voronoi(沃罗诺伊)图变换的独立数据流(dataflow)包。本文以该包的 README 与官方变换文档为主体,结合src/Voronoi.js源码实现、test/voronoi-test.js测试用例以及 TypeScript 类型定义,系统讲解 voronoi 变换的参数语义、SVG 路径输出格式、底层 Delaunay 三角剖分原理与实战用法。读完本文,你将能够独立在 Vega 规范中使用voronoi变换实现"鼠标悬停自动吸附最近数据点"等经典交互方案。

包定位与适用场景

packages/vega-voronoi/README.md将本包定位为 "Voronoi diagram transform for Vega dataflows",即 Vega 数据流中的 Voronoi 图变换,为 Vega 提供一个名为Voronoi的数据变换。Voronoi 图将平面按照一组输入点划分为若干单元(cell),每个单元包含距离该单元内种子点最近的区域——空间中的任意位置都可以快速判断其最近的输入点是谁。

在 Vega 中最典型的应用是交互式最近点识别:例如把 Voronoi 单元渲染成透明路径,当鼠标悬停到任意单元上时,即可自动选中距离鼠标最近的原始数据点,无需逐点计算距离。docs/docs/transforms/voronoi.md明确说明:"A Voronoi diagram can be used to automatically select the data point closest to the mouse cursor",这正是本变换的核心价值。

包结构与依赖关系

从仓库结构看,vega-voronoi包非常精简,仅包含 4 个核心文件:

  • src/Voronoi.js:变换核心实现(全部逻辑约 64 行);
  • test/voronoi-test.js:单元测试;
  • index.js:入口,导出voronoi
  • package.json:包元数据与依赖声明。

根据 package.json,其运行依赖仅有三项:

依赖作用
d3-delaunay(^6.0.4)提供Delaunay.from(...).voronoi(bounds)底层几何计算
vega-dataflow(^6.1.2)提供Transform基类与数据流 pulse 机制
vega-util(^2.1.0)提供inherits等工具函数

在 packages/vega/index.js 中,主包通过import * as voronoi from 'vega-voronoi'引入并注册该变换,因此在 Vega 主包环境下可直接使用"type": "voronoi"的变换声明,无需单独加载插件。

变换参数详解

Voronoi 变换共接受 4 个参数,其定义集中在 src/Voronoi.js 的Voronoi.Definition中。下表与官方文档 voronoi.md 保持一致并补充了源码中的默认值:

参数类型必填默认值说明
xField输入数据点的 x 坐标字段
yField输入数据点的 y 坐标字段
extentArray[][[-1e5, -1e5], [1e5, 1e5]]Voronoi 单元的裁剪范围,格式为[[x0, y0], [x1, y1]],其中 x0 为左边界、y0 为上边界、x1 为右边界、y1 为下边界
sizeNumber[]extent的替代写法,等价于把裁剪范围设置为[[0, 0], size]
asStringpath输出字段名,保存 Voronoi 单元的 SVG path 字符串

关键语义说明:

  • xy必填:两者均需指向数据中的数值字段,源码中直接以字段访问器形式传入Delaunay.from(data, _.x, _.y)
  • extentsize互斥:源码中size优先,二者都未提供时才采用默认裁剪范围[-1e5, -1e5, 1e5, 1e5](即向负、正两个方向各裁剪 10,000 像素)。默认范围足够大,适合绝大多数屏幕坐标系。
  • as默认写入path字段:每个输入数据行会新增一个字符串字段(默认名为path),其值为对应 Voronoi 单元的多边形路径;若某数据点无法构成有效单元,则该字段置为null(详见后文"退化情形")。

用法示例

基础用法

官方文档 voronoi.md 给出的最小用法如下:

{"type": "voronoi", "x": "layout_x", "y": "layout_y", "as": "cell"}

该变换基于先前计算出的布局坐标layout_xlayout_y计算 Voronoi 单元路径,并把结果写入新字段cell。随后即可用pathmark 引用该字段渲染单元:

{ "type": "path", "from": {"data": "points"}, "transform": [ {"type": "voronoi", "x": "datum.x", "y": "datum.y", "size": [{"signal": "width"}, {"signal": "height"}]} ], "encode": { "enter": {"stroke": {"value": "firebrick"}, "fill": {"value": "transparent"}} } }

完整的交互式示例

仓库中的 docs/docs/transforms/voronoi.vg.json 是一个可直接运行的交互式演示:单击(或拖拽)添加数据点,Shift 单击(或 Shift 拖拽)删除数据点。其核心结构展示了 voronoi 变换与信号、触发器协同工作的完整模式:

  • 信号层addPoint信号监听click[!event.shiftKey]mousemove[event.buttons && !event.shiftKey]事件,通过invert('xscale', x())把像素坐标反算为数据坐标;remPoint信号监听path:click[event.shiftKey]等事件返回被删除的数据对象。
  • 数据层table数据集通过触发器{"trigger": "addPoint", "insert": "addPoint"}{"trigger": "remPoint", "remove": "remPoint"}动态增删数据行。
  • 变换层pathmark 的transform数组中声明{"type": "voronoi", "x": "datum.x", "y": "datum.y", "size": [{"signal": "width"}, {"signal": "height"}]},其中size直接绑定画布宽高信号,确保单元始终覆盖整个可视区域。
  • 渲染层pointsmark(symbol 类型,zindex: 1)负责绘制数据点,pathmark 负责绘制半透明单元边界。

值得注意:示例中 voronoi 变换的输入是pathmark 的datum(即经过xscale/yscale映射后的屏幕坐标),输出被写回path字段供当前 mark 直接使用——这是 voronoi 变换"输出即路径、随用随算"的典型用法。

实战案例:机场地图鼠标悬停加速

docs/tutorials/airports/index.md(第 567~596 行)演示了本变换最经典的实战场景。原始机场地图需要精确悬停在细小圆点上才能查看信息,体验较差。解决方案是为每个机场生成 Voronoi 单元,使鼠标只要靠近某个机场就会被吸附:

{"type": "voronoi", "x": "x", "y": "y"}

教程明确说明:"Thevoronoitransform computes the enclosing cells for each airport using thexandycoordinates. The output is an SVG path string written to thepathproperty." 完整可运行版本见 docs/tutorials/airports/airports-voronoi.vg.json:该文件通过hover信号监听@cell:mouseover/@cell:mouseout事件,配合title信号实时显示hover.name + ' (' + hover.iata + ')',实现了"鼠标靠近任意机场即高亮并显示名称"的交互效果。

源码实现深度剖析

核心流程

src/Voronoi.js 的transform方法完整实现了变换逻辑,可分为三步:

  1. 空数据短路:若pulse.source为空(!data || !data.length),直接返回原 pulse,不做任何计算。

  2. 确定裁剪范围并构建图:按"sizeextent→ 默认范围"的优先级归一化边界,然后调用:

    const voronoi = this.value = Delaunay.from(data, _.x, _.y).voronoi(s);

    这里Delaunay.from(data, x, y)先用输入点构建 Delaunay 三角剖分,再调用.voronoi(s)生成以s = [x0, y0, x1, y1]为裁剪边界的 Voronoi 图。注意结果被缓存在this.value中,这为后续增量更新提供了基础。

  3. 逐点输出路径:遍历每个数据行,调用voronoi.cellPolygon(i)取得第 i 个点的单元多边形,转换为 SVG path 字符串写入输出字段。

SVG path 的生成细节

多边形转 path 的逻辑在 src/Voronoi.js 的两个辅助函数中:

function toPathString(p) { const x = p[0][0], y = p[0][1]; let n = p.length - 1; for (; p[n][0] === x && p[n][1] === y; --n); return 'M' + p.slice(0, n + 1).join('L') + 'Z'; } function isPoint(p) { return p.length === 2 && p[0][0] === p[1][0] && p[0][1] === p[1][1]; }

两个细节值得注意:

  • 去重闭合点cellPolygon返回的多边形首尾顶点重合(起点即终点),toPathString会从尾部向前跳过与起点重合的顶点,避免生成冗余线段,然后以M...L...Z格式拼接。
  • 退化多边形处理:若多边形只包含两个完全相同的点(即单元退化成一个点,例如边界上恰好重合的输入点),isPoint判定为真,此时不生成路径而是写入null。变换代码中对应的判断为polygon && !isPoint(polygon) ? toPathString(polygon) : null

数据流语义

最后一行return pulse.reflow(_.modified()).modifies(as);体现了 Vega 数据流的两个关键语义:

  • pulse.reflow(_.modified()):标记所有数据行的"元数据"可能变化,通知下游重排(reflow);当x/y字段或裁剪参数被修改时触发全量重算。
  • .modifies(as):声明输出字段(默认path)已被修改,使依赖该字段的编码(encoding)能够正确更新,同时配合Voronoi.Definition中声明的'metadata': {'modifies': true},让 Vega 在优化阶段就知道该变换会改写数据字段。

测试用例验证

test/voronoi-test.js 使用tape编写,通过vega-dataflowDataflowchangesetvega-transformsCollect构造真实数据流进行验证,三个用例覆盖了点数量的边界:

3 个点(输入(10,10)(20,10)(10,20)size: [30, 20])的断言:

out[0].path === 'M0,0L15,0L15,15L0,15Z' out[1].path === 'M30,0L30,20L20,20L15,15L15,0Z' out[2].path === 'M0,20L0,15L15,15L20,20Z'

1 个点:单个输入点时,Voronoi 单元退化为整个裁剪矩形,得到'M30,0L30,20L0,20L0,0Z'

2 个点:两个输入点各占半幅矩形,得到'M0,20L0,0L15,0L15,20Z''M30,0L30,20L15,20L15,0Z'

这些断言直接验证了size参数([30, 20]对应范围[0,0][30,20])与路径生成的正确性,是理解输出格式的最直观证据。

TypeScript 类型定义

在 packages/vega-typings/types/spec/transform.d.ts 中,VoronoiTransform接口对参数做了类型约束,与源码Definition完全对应:

export interface VoronoiTransform { type: 'voronoi'; x: FieldRef; y: FieldRef; size?: Vector2<number | SignalRef> | SignalRef; extent?: Vector2<Vector2<number | SignalRef> | SignalRef> | SignalRef; as?: string | SignalRef; }

可见size是二元向量[width, height]extent是"二元向量的二元向量"[[x0, y0], [x1, y1]],且二者均支持SignalRef(即可以绑定信号,如示例中的{"signal": "width"})。TypeScript 类型测试见 packages/vega-typings/tests/spec/valid/airports.ts,它验证了 voronoi 变换在类型检查下的合法写法。

使用注意事项

综合源码、测试与官方文档,使用 voronoi 变换时应注意以下几点:

  1. xy必须是同一批数据行的坐标字段:变换基于"每个输入行 = 一个种子点"的假设,输入行与输出行一一对应,不改变数据行数。
  2. 裁剪范围决定单元形状:单元在裁剪边界处会被截断为矩形边缘形状,默认范围[-1e5, 1e5]对绝大多数场景足够;若坐标值超出该范围,单元可能无法完整覆盖,应显式指定extentsize
  3. size优先于extent:源码中_.size非空即采用[0, 0, size[0], size[1]],只有未提供size时才读取extent
  4. 输出字段可被下游直接消费:默认写入path字段,可由pathmark 直接引用;交互式悬停示例中则利用@cell:mouseover事件选择器将datum传给信号。
  5. 退化点输出null:无法构成有效多边形的点(如重合点导致的退化单元)其输出字段为null,渲染层需能容忍空路径。
  6. 数据流增量更新:变换会缓存 Voronoi 图到this.value,并通过pulse.reflowmodifies通知下游,动态增删点(如voronoi.vg.json中的点击添加/Shift 点击删除)可高效重算。

小结

vega-voronoi是一个体积小巧但能力完整的 Vega 数据变换包:对外只需声明xyextent/sizeas四个参数,对内则借助d3-delaunay的 Delaunay 三角剖分完成几何计算,输出可直接渲染的 SVG path 字符串。无论是为散点图添加"最近点吸附"交互、为地图添加悬停加速,还是构造任意的邻近区域划分,voronoi 变换都是 Vega 数据流中不可或缺的一环。想进一步了解其在完整 Vega 规范中的位置,可参考 docs/docs/transforms.md 与 docs/docs/transforms/voronoi.md;若需深入交互式示例,可直接运行 docs/docs/transforms/voronoi.vg.json 与 docs/tutorials/airports/airports-voronoi.vg.json。

  • 数据可视化

【免费下载链接】vega

A visualization grammar.

项目地址:https://gitcode.com/gh_mirrors/ve/vega
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询