Observable Plot 是一个免费的开源 JavaScript 库(https://github.com/observablehq/plot),用于可视化表格数据,专注于加速探索性数据分析。它拥有简洁、易记且表达力强的界面,采用了由 Leland Wilkinson 和 Hadley Wickham 推广的图形语法风格中的刻度(scales)和分层标记(layered marks),并受到 Jacques Bertin 早期理念的启发。此外,还提供了大量可供学习和复制粘贴的示例(https://observablehq.com/)。
但「采用了图形语法风格」到底意味着什么?Observable Plot 实现了图形语法的全部,还是只是一部分?它和 ggplot2 这种「图形语法标杆」有什么区别?本文从图形语法的理论出发,拆解 Observable Plot 的实现,最后讨论图形语法的边界与补齐方案。
一、什么是图形语法
1.1 传统图表库的痛点
传统图表库(如 ECharts、Highcharts)采用「图表类型」思维:你想画散点图就选 type: 'scatter',想画柱状图就选 type: 'bar'。每种图表是一个封装好的黑盒,内部逻辑不可见、不可组合。
这种模式优势显著,有很多成熟的现成图表,但仍然有一些痛点:
-
组合困难:想在散点图上加一条回归线?需要两个 series 手动配置,数据格式还不一样
-
扩展困难:想要一个库没有的图表类型?只能等官方更新或自己写插件
-
概念不统一:每种图表的配置项各搞一套,学习成本高
1.2 图形语法的核心思想
1999 年,Leland Wilkinson 在《The Grammar of Graphics》中提出了一个革命性的思想:一张图不是一种「类型」,而是一组「构件」的组合。就像自然语言中句子由主语、谓语、宾语组合而成,图表由数据、映射、统计变换、几何对象、标尺等构件组合而成。
2010 年,Hadley Wickham 在 ggplot2 中将这一思想落地为「分层图形语法」,核心是九大构件:
| 构件 | 作用 | 类比 |
|---|---|---|
| Data | 输入数据(长表格式) | 食材 |
| Aes | 数据字段→视觉属性的映射 | 菜谱(哪样食材放哪) |
| Stat | 统计变换(分箱、平滑、计数) | 烹饪加工 |
| Geom | 几何对象(点、线、柱) | 装盘形状 |
| Scale | 数据值→像素值的标尺 | 计量单位换算 |
| Coord | 坐标系(笛卡尔、极坐标) | 餐桌形状 |
| Position | 位置调整(堆叠、并排、抖动) | 摆盘位置 |
| Facet | 分面(按字段分组画子图) | 分餐制 |
| Theme | 主题(颜色、字体、间距) | 餐厅装修风格 |
解耦是图形语法的灵魂。Stat 和 Geom 是解耦的:同一个分箱统计(stat_bin)可以配柱状图(geom_bar),也可以配散点图(geom_point)。同一个平滑统计(stat_smooth)可以配线(geom_line),也可以配置信区间(geom_ribbon)。这种自由组合是传统图表库做不到的。
1.3 渲染流水线
九大构件按固定的流水线协作:
text
原始数据
→ stat 统计变换(可选)→ 中间数据
→ aes 映射(确定用哪些字段)
→ scale 映射(数据值 → 像素值)
→ geom 渲染(生成图形元素)
→ position 位置调整(可选)
→ coord 坐标变换(最后一步)
→ 叠加到 layer 中
→ 绘制 axis/legend(基于 scale)
→ theme 样式应用
注意几个关键顺序:Stat 在 Geom 之前(统计变换输出中间数据,Geom 消费中间数据);Coord 在最后(坐标系变换不影响统计计算);Axis/Legend 是 Scale 的副产品。
二、图形语法举例
用一个具体例子来理解九大构件如何协作。数据集是经典的 mtcars(32 款汽车的参数),我们要画一张「车重 vs 油耗」的散点图,并按气缸数着色,再加一条回归线。
2.1 ggplot2 实现
r
ggplot(mtcars, aes(x = wt, y = mpg, color = factor(cyl))) +
geom_point(size = 3) +
geom_smooth(method = "lm", se = FALSE, color = "black") +
scale_x_continuous(name = "车重 (1000 lbs)") +
scale_y_continuous(name = "油耗 (mpg)") +
scale_color_brewer(palette = "Set1", name = "气缸数") +
theme_minimal()
2.2 逐构件拆解
| 构件 | 在本例中的作用 | 具体值 |
|---|---|---|
| Data | 输入数据集 | mtcars(32行×11列的长表) |
| Aes | 字段→视觉通道映射 | x=wt, y=mpg, color=factor(cyl) |
| Stat | 统计变换 | 第一层无(原始数据);第二层 stat_smooth(线性回归) |
| Geom | 几何对象 | 第一层 geom_point(散点);第二层 geom_smooth(线) |
| Scale | 数据→像素映射 | x/y 线性标尺,color 用 Set1 色板 |
| Coord | 坐标系 | 默认笛卡尔坐标 |
| Position | 位置调整 | 默认 identity(不调整) |
| Facet | 分面 | 无(单图) |
| Theme | 主题 | theme_minimal(极简风格) |
2.3 数据流向
第一层(散点):原始 mtcars 数据 → aes 取出 wt/mpg/cyl → scale 映射成坐标和颜色 → geom_point 生成 32 个 circle。
第二层(回归线):原始 mtcars 数据 → stat_smooth 做线性回归,输出 2 个点 {x, y} → scale 映射成坐标 → geom_smooth 生成 1 条 path。
两层叠加,坐标轴和图例基于 scale 自动生成,主题应用全局样式。这就是图形语法的完整流水线。
2.4 解耦的威力
如果想把这张图改成直方图,只需要改 stat 和 geom:
r
# 散点图
ggplot(mtcars, aes(x = mpg)) + geom_point(stat = "bin")
# 直方图(同样的数据,同样的 aes,换 stat + geom)
ggplot(mtcars, aes(x = mpg)) + geom_histogram(bins = 10)
数据、aes、scale 都不变,只换了 stat(bin)和 geom(bar),就从散点图变成了直方图。这就是解耦的表达力。
三、Observable Plot 实现了哪些部件
3.1 Observable Plot 的设计
Observable Plot 的定位是「轻量化图形语法子集」。它不追求实现图形语法的全部,而是在「简洁 API」和「语法完整性」之间做取舍。典型特点如:
-
stat 和 mark 耦合:不区分 stat 和 geom,统一称为 mark(标记)。如 Plot.dot(散点)、Plot.line(折线)、Plot.bin(直方图,内置分箱统计)
-
函数式 API:每个 mark 是一个函数,返回一个描述对象,传入 Plot.plot() 渲染
-
原生交互:tooltip、brush、zoom 内置,不需要额外配置
-
基于 D3:底层用 D3 的 scale、shape、axis,站在巨人肩膀上
3.2 九大构件对应关系
| 构件 | 实现度 | Observable Plot 对应 | 说明 |
|---|---|---|---|
| Data | 完整 | data 参数(数组/typed array/列对象) | 支持多种数据格式,自动识别 |
| Aes | 部分 | channels(x/y/fill/stroke/r/opacity...) | 通道映射完整,但无独立 aes 抽象,直接写在 mark 里 |
| Stat | 部分 | 内置在 mark 中(bin/interval/hexgrid...) | 统计逻辑固化在 mark 中,不可独立插拔,无自定义 stat |
| Geom | 部分 | marks(dot/line/bar/area/rect...) | 几何对象丰富,但与 stat 耦合,不可自由组合 |
| Scale | 完整 | scales 配置(x/y/color/opacity...) | 基于 D3-scale,支持 linear/log/ordinal/time/band 等 |
| Coord | 部分 | projection(地理投影)、polar(极坐标,有限) | 地理投影强,但极坐标仅支持部分 mark,无 coord_flip/coord_trans |
| Position | 部分 | offset 参数(stack/normalize/jitter) | 支持堆叠、归一化、抖动,但无 dodge(分组并排需手动处理) |
| Facet | 部分 | fx/fy 通道 | 支持一维/二维分面,但无 free scales,布局灵活性有限 |
| Theme | 较完整 | style/color/grid 配置 + 预设主题 | 支持自定义样式,有 light/dark 预设,但不如 ggplot2 精细 |
3.3 已实现部件详解
Scale(标尺):最完整的构件
Observable Plot 的 scale 体系基于 D3-scale,是九大构件中实现最完整的。支持:
-
连续型:linear、log、pow、sqrt、symlog、time、utc
-
离散型:ordinal、band、point、quantile、quantize、threshold
-
颜色比例尺:categorical、sequential、diverging、cyclic(内置 D3 色板)
-
nice/ticks/label:自动优化刻度和标签
javascript
Plot.plot({
x: { type: "log", label: "车重 (log scale)" },
y: { grid: true, label: "油耗" },
color: { type: "ordinal", scheme: "Set1", legend: true },
marks: [Plot.dot(mtcars, { x: "wt", y: "mpg", fill: "cyl" })]
})
Mark(标记):stat + geom 的耦合体
Observable Plot 不区分 stat 和 geom,统一为 mark。每个 mark 内置了统计逻辑和渲染逻辑:
| Mark | 内置统计 | 对应 ggplot2 |
|---|---|---|
| dot | 无 | geom_point |
| line | 无(按 x 排序) | geom_line |
| barY/barX | 无 | geom_col |
| rect | 无 | geom_tile |
| area | 无 | geom_area |
| bin | 分箱计数 | stat_bin + geom_bar |
| hexgrid | 六边形分箱 | geom_hex |
| density | 核密度估计 | stat_density + geom_density |
| linearRegression | 线性回归 | stat_smooth(method="lm") |
| loessRegression | LOESS 回归 | stat_smooth(method="loess") |
| boxplot | 箱线图统计 | stat_boxplot + geom_boxplot |
| contour | 等高线计算 | geom_contour |
这种设计的好处是 API 简洁------画直方图只需要 Plot.bin(data, {x: "mpg"}),不需要分别指定 stat 和 geom。代价是失去了 stat-geom 自由组合的能力。
Facet(分面):fx/fy 通道
Observable Plot 用 fx/fy 通道实现分面,非常简洁:
javascript
Plot.plot({
fx: { label: "气缸数" },
marks: [
Plot.dot(mtcars, { x: "wt", y: "mpg", fx: "cyl" }),
Plot.linearRegressionY(mtcars, { x: "wt", y: "mpg", fx: "cyl", stroke: "red" })
]
})
但限制是:所有分面共享相同的 scale(无 free scales),布局是规则网格,不支持复杂的自定义布局。
交互:原生支持
这是 Observable Plot 相比 ggplot2 的最大优势------交互是内置的,不需要额外配置:
javascript
Plot.plot({
marks: [
Plot.dot(mtcars, {
x: "wt", y: "mpg", fill: "cyl",
tip: true, // tooltip,一行配置
brush: { filter: null } // 框选
})
]
})
ggplot2 本身是静态的,交互需要通过 plotly::ggplotly() 转换。而 Observable Plot 的 tooltip、brush、zoom 都是原生的。
3.4 Observable Plot 的不足
-
stat 和 mark 耦合,无自定义 stat:统计逻辑固化在 mark 中,不能像 ggplot2 那样自由组合(如 stat_bin + geom_point)。也不能注册自定义的全局复用 stat。
-
无独立 aes 抽象:映射直接写在每个 mark 的参数里,没有全局 aes + 局部 aes 的继承机制。多个 mark 共享映射时需要重复写。
-
Coord 能力有限:地理投影(projection)很强,但极坐标仅支持部分 mark,没有 coord_flip(x/y 翻转)、coord_trans(坐标变换)。饼图的实现不方便,需要使用 d3 arc。
-
Position 不完整:支持 stack/normalize/jitter,但没有 dodge(分组柱状图的并排),需要手动计算偏移或用 groupX。
-
Facet 无 free scales:所有分面共享 x/y scale,不能让每个子图有独立的坐标范围。
-
无自定义 geom 机制:不能像 ggplot2 那样编写自定义 Geom 类并注册。自定义图形需要用 Plot.vector 或直接操作 SVG。
-
双轴支持弱:没有 sec_axis(次坐标轴),双 y 轴图需要手动处理。
3.5 Observable Plot 的优势
-
API 极简 :画一张图只需要几行代码。散点图
Plot.dot(data, {x, y}),直方图Plot.bin(data, {x})。学习成本远低于 ggplot2。 -
原生交互:tooltip、brush、zoom 内置,一行配置开启。这是 ggplot2 做不到的。
-
基于 D3 生态:scale、shape、axis、projection 都用 D3,质量有保障。可以和 D3 代码混用。
-
Observable 原生集成:在 Observable Notebook 中零配置使用,响应式更新。也支持 Quarto、R Markdown。
-
默认样式好:不需要配置主题就能得到美观的图表,网格线、颜色、字体都有精心设计的默认值。
-
类型安全:TypeScript 原生支持,IDE 自动补全。
3.6 同一张图的代码对比
javascript
Plot.plot({
grid: true,
x: { label: "车重 (1000 lbs)" },
y: { label: "油耗 (mpg)" },
color: { legend: true, label: "气缸数" },
marks: [
Plot.dot(mtcars, { x: "wt", y: "mpg", fill: "cyl", tip: true }),
Plot.linearRegressionY(mtcars, { x: "wt", y: "mpg", stroke: "black" })
]
})
r
ggplot(mtcars, aes(x = wt, y = mpg, color = factor(cyl))) +
geom_point(size = 3) +
geom_smooth(method = "lm", se = FALSE, color = "black") +
labs(x = "车重 (1000 lbs)", y = "油耗 (mpg)", color = "气缸数") +
theme_minimal() +
theme(legend.position = "right")
# 交互需要 plotly::ggplotly() 转换
Observable Plot 代码更短,且原生支持 tooltip。但 ggplot2 的 stat-geom 解耦更清晰,扩展能力更强。
四、延伸:图形语法的边界和解决方案
4.1 图形语法的六大短板
通过前面的分析可以看到,即使是 ggplot2 这种「图形语法标杆」,也有一些固有的短板------这些不是实现问题,而是图形语法本身的设计边界:
| 短板 | 原因 | 典型图表 |
|---|---|---|
| 拓扑布局 | 图形语法绑定二维表,无节点-边抽象 | 力导向图、桑基图、树图、和弦图 |
| 自由排版 | Facet 是规则网格,非自由布局 | 仪表盘、多图组合、不规则排版 |
| 交互 | ggplot2 设计为静态输出 | tooltip、brush、zoom、下钻 |
| 3D | 图形语法本质是 2D 的 | 3D 散点、3D 曲面、3D 柱状 |
| 地图 | 需要地理投影和空间数据结构 | choropleth、飞线、热力地图 |
| 大数据性能 | SVG/grid 渲染在万级以上卡顿 | 百万级散点、实时流数据 |
4.2 各短板的解决方案
拓扑布局:专用图可视化引擎
图形语法不擅长节点-边关系图,因为这类图需要布局算法(力导向、树布局、桑基布局),而不是简单的 x/y 映射。解决方案:
-
AntV G6:专业的图可视化引擎,内置力导向、树、DAG、桑基等布局,支持自定义节点和边
-
D3-force / D3-sankey / D3-hierarchy:D3 的布局算法模块,可以和 D3 的渲染配合
-
Cytoscape.js:生物信息学领域常用的网络图库
-
ECharts graph/sankey/tree:内置拓扑布局的系列配置
-
ggplot2 + ggraph:ggplot2 的扩展包,用图形语法画网络图(但布局仍需外部算法)
自由排版:组合布局工具
图形语法的 facet 是规则网格,不适合复杂的仪表盘布局。解决方案:
-
patchwork(R):ggplot2 的多图组合包,支持自定义布局网格
-
cowplot(R):另一个 ggplot2 组合工具
-
CSS Grid / Flexbox(Web):用 CSS 布局多个图表容器
-
ECharts grid:多 grid 布局,支持任意位置和大小的子图
交互:Web 原生或转换
-
Observable Plot / Vega-Lite / AntV G2:Web 端图形语法库,原生支持交互
-
plotly::ggplotly():将 ggplot2 转换为交互式 Plotly 图表
-
shiny(R):用 R 做交互式可视化应用
-
D3.js:完全自定义交互
3D:专用 3D 引擎
-
Three.js:Web 端 3D 渲染的事实标准
-
Plotly 3D:支持 3D 散点、曲面、柱状
-
ECharts GL:ECharts 的 3D 扩展
-
rgl(R):R 的 3D 可视化包
地图:地理投影库
-
D3-geo:D3 的地理投影模块,支持 100+ 投影
-
Observable Plot projection:内置地理投影支持
-
ECharts map:内置地图注册、飞线、热力
-
ggplot2 + sf/ggspatial:R 的空间数据扩展
大数据性能:Canvas/WebGL 渲染
-
WebScatter / Deck.gl:WebGL 渲染,支持百万级数据点
-
ECharts large 模式:Canvas 增量渲染
-
Datashader(Python):服务端预渲染大数据
-
Observable Plot raster:栅格化渲染,提升大数据性能
4.3 组合使用是最佳实践
没有一个库能搞定所有场景。实际项目中通常是组合使用:
-
数据分析阶段:用 ggplot2/plotnine 做探索性分析(图形语法的灵活性最高)
-
Web 展示阶段:统计图表用 Observable Plot/G2,业务大屏用 ECharts,网络图用 G6
-
自定义需求:用 D3.js 实现图形语法库不支持的图表
-
大数据/3D:用 Deck.gl/Three.js 等专用引擎
总结
Observable Plot 是图形语法的「轻量化子集」------它完整实现了 Scale(标尺)和 Layered Marks(分层标记),但将 Stat 和 Geom 耦合为 Mark,牺牲了自由组合能力换取了 API 的简洁。它的优势是极简的 API、原生交互、D3 生态和 Observable 集成;不足是无自定义 stat/geom、Coord/Position/Facet 能力有限。
图形语法本身也有边界------拓扑布局、自由排版、3D、地图、大数据性能等场景需要专用库补齐。理解这些边界,才能在合适的场景选择合适的工具。