Observable JS
1 概述
Quarto 原生支持 Observable JS 它是对 JavaScript 的增强。 像 Excel 一样当修改了某个单元格, 所有相关的单元格都得到更新
Observable官网 的口号是 : 可计算、可协作、可智能体化的文档, 在这里每个人都能用数据回答问题, 它在 Quarto 被归类到 “Interactivity 交互性”
注意 Observable JS 是做数据分析的, 并不擅长数学和曲线。 Observable JS 的页面需要通过 http:// 或 https:// 访问, 不能通过 file:// 访问
2 范例
从这个范例可以看出来数学软件也做不了 Observable JS 所擅长的事
范例 的数据源自表格 palmer-penguins.csv 它是关于多种企鹅的数据。 下面摘取了表格几行并做一些简单说明 :
用户通过控件调整观察对象, 图表会随之而 “自动改变” :
Note企鹅”体重数量”图
观察对象
图表
企鹅的种类 : Adelie阿德利企鹅, Chinstrap帽带企鹅, Gentoo金图企鹅
首先创建一个 {ojs} 单元格,使用 FileAttachment 从 CSV 文件读取数据 :
```{ojs}
data = FileAttachment("Attachments/palmer-penguins.csv").csv({ typed: true })
```为了让用户可以随时方便地修改喙长和岛屿, 利用可视化输入控件 viewof :
```{ojs}
viewof bill_length_min = Inputs.range(
[32, 50],
{value: 35, step: 1, label: "最小喙长 : "}
)
viewof islands = Inputs.checkbox(
["Torgersen", "Biscoe", "Dream"],
{ value: ["Torgersen", "Biscoe"],
label: "岛屿 : "
}
)
```再编写筛选函数,它使用 bill_length_min 和 island 的值来转换从 CSV 读取的 data :
```{ojs}
filtered = data.filter(function(penguin) {
return bill_length_min < penguin.bill_length_mm &&
islands.includes(penguin.island);
})
```最后使用 Observable Plot 来绘制筛选后的数据 :
```{ojs}
Plot.rectY(filtered,
Plot.binX(
{y: "count"},
{x: "body_mass_g", fill: "species", thresholds: 20}
))
.plot({
fy: { label: "种类" },
facet: {
data: filtered,
x: "sex",
y: "species",
marginRight: 80
},
marks: [
Plot.frame(),
]
}
)
```3 库
3.1 概述
上面的示例用到了标准库,包括 :
Observable stdlib 用于 DOM 操作、文件处理、导入代码等的核心基础原语(primitives)
Observable Inputs 标准输入控件,包括滑块、下拉菜单、表格、复选框等
Observable Plot 用于探索性数据可视化的高层绘图库
这些库有些不用引入, 它们在 Observablehq 的 notebook , Quarto 的 {ojs} 单元格中, 都是自动可用的。 使用其他 JavaScript 库只需显式 import导入
在 OJS 中通常会用到三种类型的库 :
Observable 核心库,上面介绍的在每个文档中都自动可用
来自 npm 和 ObservableHQ 的第三方 JavaScript 库
自己创建的自定义库
3.2 标准库 (Stdlib)
Observable 标准库提供了渲染内容、加载代码和数据所依赖的核心能力。标准库中一些特别重要的组成部分包括 :
| 组件 | 描述 |
|---|---|
| DOM | 动态创建 DOM 元素 |
| FileAttachments | 以多种格式读取文件 |
| require | 从 NPM 和 GitHub 导入第三方模块 |
可以在 https://github.com/observablehq/stdlib 找到标准库的完整文档
Note
Quarto 始终嵌入特定版本的 ObservableHQ 运行时,这意味着 Quarto 可能落后于 ObservableHQ 网站上的最新版本。如果你需要使用最新版本的 Plot(或其他核心库),可以使用 import 函数直接从 CDN 导入最新版本。例如 :
```{ojs}
Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/plot/+esm")
```3.3 输入控件 (Inputs)
Observable 输入控件 提供了可以通过 viewof 关键字, 用一个人机交互的控件来修改变量值 :
| 组件 | 描述 |
|---|---|
| Radio | 从互斥的选项集中进行选择 |
| Checkbox | 从列表中选择一个或多个选项 |
| Range | 用于连续数值的滑块 |
| Select | 下拉选择框 |
| Table | 从表格中选择一行或多行 |
下面逐一展示每种控件的用法
3.3.1 按钮与开关
Inputs.button 生成按钮,reduce 在每次点击时更新值 :
Inputs.toggle 生成布尔开关 :
3.3.2 单选与多选
Inputs.radio 从互斥的选项中选一个 :
Inputs.checkbox 从列表中选择一个或多个选项 :
3.3.3 数值输入
Inputs.range 是连续数值滑块 :
Inputs.number 与 Inputs.range 类似,但只显示数字输入框 :
3.3.4 选择、搜索与表格
Inputs.select 是下拉选择框 :
Inputs.search 提供带搜索的输入框 :
Inputs.table 从表格中选择一行 :
还可以选中多行 :
3.3.5 文本输入
Inputs.text 是单行文本,Inputs.textarea 是多行文本 :
下面这些控件与 Inputs.text 类似,只是输入类型不同 :
3.3.6 日期与时间
Inputs.date 只有日期可以选择, Inputs.datetime 日期和时间都可以选择 :
3.3.7 文件上传
Inputs.file 用于选择本地文件, 并传给浏览器。 他并不依赖服务器 :
3.4 Plot
Observable Plot 用于数据可视化的 JavaScript 库。 使用它需要理解一组核心概念 标记 Marks、标度 Scales、变换 Transforms、 分面 Facets等 :
下面简单的几行代码, 绘制运动员体重直方图 :
athletes = FileAttachment("Attachments/athletes.csv").csv({typed: true})
Plot.plot({
x: { label: "体重 (kg)" },
y: { label: "人数", grid: true },
grid: true,
facet: {
data: athletes,
y: "sex"
},
marks: [
Plot.rectY(
athletes,
Plot.binX({y: "count"}, {x: "weight", fill: "sex"})
),
Plot.ruleY([0])
]
})3.5 D3
D3.js 是一个基于数据来操作文档的 JavaScript 库。D3 几乎能够创建你能想象到的任何交互式图形, 它是偏底层的库。 下面是一个 “可缩放旭日图”, 点击某个节点可以放大,点击中心可以缩小 :
要在 {ojs} 单元格中使用 D3,首先使用 require 函数导入它(该函数加载托管在 jsDelivr 上的模块):
```{ojs}
d3 = require("d3@7")
```然后按需使用 D3 来创建你的可视化。 下面是创建上述可视化的单元格的前几行 :
```{ojs}
sunburst = {
const root = partition(flareData);
root.each(d => d.current = d);
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, width])
.style("font", "15px sans-serif");
// ...实现的其他部分
return svg.node();
}
```3.6 Arquero
Arquero 是一个对数据进行清洗聚合的 JavaScript 库。它遵循关系代数,并受 dplyr 的设计启发,为操作面向列的数据框提供了流畅的 API。 这里我们将导入 Arquero(aq)以及 Arquero 操作的别名(op),读取一个数据集,然后进行过滤、聚合并查看数据 :
import { aq, op } from '@uwdata/arquero'
penguins = aq.loadCSV("Attachments/palmer-penguins.csv")
penguins
.groupby('species')
.filter(p => p.body_mass_g > 0)
.rollup({
count: op.count(),
avg_mass: op.average('body_mass_g')
})
.view()要了解有关使用 Arquero 的更多信息,请参见 Introducing Arquero 教程
3.7 模块
3.7.1 NPM
标准库中的 require 函数可用于导入 npm 模块(这些模块由 jsDelivr CDN 提供):
```{ojs}
d3 = require("d3")
topojson = require("topojson")
```模块可以选择性地包含 @ 符号和版本号。例如 :
```{ojs}
d3 = require("d3@7")
```更多细节请参见 jsDelivr 文档。请注意,require 函数在解析导入时会自动添加前缀 https://cdn.jsdelivr.net/npm/,所以当 jsDelivr 文档说使用如下 URL 时 :
https://cdn.jsdelivr.net/npm/package@version/file
你只需将它传给 require :
package@version/file
3.7.2 ObservableHQ
发布在 http://observablehq.com 上的 notebook 也可以被编译并下载为 JavaScript 模块。 虽然 notebook 通常带有自己的内嵌数据集,但你在导入它们时实际上可以用自己的数据替换这些数据 !回到上面的旭日图示例,这里我们 import 一个 notebook,并使用 with 关键字为 data 提供我们自己的值 :
pdata = FileAttachment("Attachments/population.json").json()
import { chart } with { pdata as data } from "@d3/zoomable-sunburst"
chart需要注意的一个重要限制是,并非所有发布在 ObservableHQ 上的 notebook 都具有开源许可证。Notebook 需要明确标注许可证,以表明允许在 ObservableHQ 之外使用它们
你可以在 notebook 的头部区域查看其许可证。例如,下面这个 notebook 带有 ISC 许可证标记 :

在导入 ObservableHQ notebook 之前,你应该检查其许可证。有关如何操作的更多细节,请参见 notebook 许可证 的文档
4 WebSocket
下面连接币安的比特币行情推流, 实时显示 BTC/USDT 的最新成交 :
这里用 Generators.observe() 把 WebSocket 这种 “推送式” 数据源包装成 OJS 的响应式变量。 每收到一条消息就调用一次 notify(),OJS 变量 lastTrade 随之更新, 引用它的单元格也会自动刷新 :
```{ojs}
lastTrade = Generators.observe(notify => {
notify(null);
const ws = new WebSocket("wss://data-stream.binance.vision/stream?streams=btcusdt@trade");
ws.onmessage = event => notify(JSON.parse(event.data).data);
ws.onerror = () => ws.close();
return () => ws.close();
})
``````{ojs}
lastTrade ? `比特币最新成交价 :${Number(lastTrade.p).toFixed(2)} 美元` : "连接中…"
```5 OJS 单元格
OJS 代码单元格 {ojs} 的行为与传统 notebook 中的单元格略有不同,并且提供了许多选项来控制它们的显示和布局
5.1 单元格执行
OJS 与传统的编程一个关键区别在于 OJS 单元格不需要按任何特定的顺序定义
由于执行是完全响应式的, 运行时会根据单元格之间相互引用的关系,自动以正确的顺序执行。这更像是一张 Excel 表格, 而不是按线性顺序执行单元格的传统 notebook
例如, 在下面的单元格中,引用了一个尚未定义的变量 x 并做了代数运算 :
x + 5接下来才定义这个变量 x :
x = 10这段代码能够正常运行,是因为 Observable JS 运行时会自动确定单元格的正确执行顺序
5.2 单元格输出
默认情况下 OJS 单元格会在页面显示源码, 可以修改这个行为
5.2.1 代码可见性
echo 选项控制单元格是否显示源码,可以在 YAML 中进行整个文件的设置 :
---
title: "My Document"
execute:
echo: false
---如果需要某个单元格显示源码, 可以对该单元格设置 echo: true, 注意它使用的前缀是 //| :
```{ojs}
//| echo: true
filtered
```5.2.2 输出可见性
{ojs} 默认是输出内容, 但是可以利用 output 不输出内容, 只输出源码 :
```{ojs}
//| echo: true
//| output: false
filtered
```仅包含赋值语句的单元格默认不会打印其输出。下面这个赋值不会打印任何内容 :
```{ojs}
//| echo: true
dummy1 = "aHiddenAssignment"
```如果想连赋值的结果也打印出来,可以指定 output: all。点击下面箭头会看到它以 JSON 的形式展开数据 :
```{ojs}
//| echo: true
//| output: all
dummy1 = "aHiddenAssignment"
dummy2 = [{key: 1, value: [1, 2, [3, 4], dummy1]}]
```dummy1 = "aHiddenAssignment"
dummy2 = [{key: 1, value: [1, 2, [3, 4], dummy1]}]5.2.3 代码显示
添加 code-fold: true 启用折叠代码, 点击 “代码” 按钮即可展开被折叠的代码块 :
Code
Plot.plot({
facet: {
data: data,
x: "sex",
y: "species",
marginRight: 80
},
marks: [
Plot.frame(),
Plot.rectY(data,
Plot.binX(
{y: "count"},
{x: "body_mass_g", thresholds: 20, fill: "species"}
)
),
Plot.tickX(data,
Plot.groupZ(
{x: "median"},
{x: "body_mass_g",
z: d => d.sex + d.species,
stroke: "#333",
strokeWidth: 2
}
)
)
]
})更多的源码显示参考 HTML代码块
5.3 单元格布局
5.4 单元格图形
OJS 单元格也可以渲染为带编号的、可交叉引用的图形(figure)前面的范例已经加上了交叉引用 Figure 1 :
```{ojs}
//| echo: false
//| label: fig-penguin-body-mass
//| fig-cap: "按性别和种类划分的企鹅体重"
Plot.rectY(layoutFiltered,
Plot.binX(
{y: "count"},
{x: "body_mass_g", fill: "species", thresholds: 20}
))
.plot({
facet: {
data: layoutFiltered,
x: "sex",
y: "species",
marginRight: 80
},
marks: [
Plot.frame(),
]
}
)
```6 数据源
6.1 概述
将数据提供给 OJS 有多种方式 :
使用 FileAttachments API 读取 CSV、JSON、SQLite 等文件
使用
ojs_define()函数,把 Python 或 R 中处理好的数据提供给 {ojs} 单元格调用在线服务或数据存储的 Web API
6.2 File Attachments
使用标准库中的 FileAttachment 函数从文件读取数据。下面是读取并绘制的某地月度 CO2 浓度数据 :
co2Data = {
const co2data = await FileAttachment("Attachments/co2_mm.csv")
.csv({ typed: true } );
return co2data.map(d => {
d["decimal date"] = Number(d["decimal date"]);
d.average = Number(d.average);
return d;
});
}
Plot.plot({
marks: [
Plot.line(co2Data,
{ x: "decimal date", y: "average"},
{ stroke: "black" }
)
]
})注意我们给 csv() 函数指定了 typed: true 选项。指定该选项后,会使用 d3.autoType 自动检测数字、日期等,并转换成正确的 JavaScript 类型。当你确认数据适合自动类型检测时,强烈推荐使用。
下面是可以读取结构化数据格式的方法 :
| 方法 | 描述 |
|---|---|
| csv | 逗号分隔值 |
| tsv | 制表符分隔值 |
| json | JSON(JavaScript 对象) |
| sqlite | SQLite 数据库客户端 |
| arrow | Apache Arrow IPC 文件(未压缩) |
也可以使用其他方法把原始数据读成 blob、text、image 或 stream。
注意,如果使用 arrow() 方法,Apache Arrow IPC 文件(Feather V2 文件)必须写成未压缩的格式。例如 :
```{r}
arrow::write_feather(
mtcars,
"data.arrow",
compression = "uncompressed"
)
``````{ojs}
data = FileAttachment("data.arrow").arrow()
```6.3 Python 和 R
用于 OJS 的数据有时候需要先用 Python 或 R 读取并预处理, 再通过 ojs_define() 函数把它提供给 {ojs} 单元格 :
```{r}
#| output: false
#| message: false
library(readr)
library(dplyr)
co2 = read_csv("Attachments/co2_mm.csv") %>%
group_by(year) %>%
summarize(max = max(average))
ojs_define(co2data = co2)
```现在用 Observable Plot 绘制数据 :
```{ojs}
//| echo: fenced
yearlyChart = Plot.plot({
marks: [
Plot.line(transpose(co2data),
{x: "year", y: "max"},
{ stroke: "black" }
)
]}
)
```6.3.1 转置
上面 OJS 代码里还有一个技巧 : 在绘图之前对 co2data 调用了 transpose 函数。 transpose 内置在 OJS 引擎中, 它把 R 面向列的数据集转换成 JavaScript 绘图库使用的面向行的数据集 :
下面这个从 R 输出的 JSON 数据 :
{
"year": [1958, 1959, 1960],
"max": [317.51, 318.29, 320.04]
}经过 transpose 调用后转换成 JavaScript 绘图的行数据 :
[
{ "year": 1959, "max": 317.51 },
{ "year": 1960, "max": 318.29 },
{ "year": 1960, "max": 320.04 }
]6.4 网络 API
可以使用 d3.json() 函数从 Web 服务和数据源读取 JSON 数据。下面查询 GitHub API,获取 Python pandas 包的贡献数据(d3 已在前面导入) :
contributors = await d3.json(
"https://api.github.com/repos/pandas-dev/pandas/stats/contributors"
)
commits = contributors.map(contributor => {
const author = contributor.author;
return {
name: author.login,
title: author.login,
group: author.type,
value: contributor.total
}
})按提交次数降序查看数据 :
Inputs.table(commits, { sort: "value", reverse: true })7 REST API
REST API 是一个 URL, 可以写入或者读取一个 JSON 文件。 https://dummyjson.com/quotes/random 是公共 REST API 服务, 提供随意的名言 :
8 代码复用
当构建较大 Quarto 项目时, 可能希望在多个页面之间复用 ojs 代码、数据和输出
8.1 模块
Quarto 的 ojs 代码块直接支持 JavaScript 模块。 假设有下面这个源文件 square.js :
square.js
export function square(x) {
return x * x;
}那么你可以像下面这样导入并使用 square() 函数 :
```{ojs}
//| echo: fenced
import { square } from "./Attachments/square.js"
square(5)
```8.2 数据
前面介绍了用 Python 或 R 预处理数据, 再通过 ojs_define() 函数提供给 OJS, 这可以跨 .qmd 共享数据。 本页面前面已经准备了 co2data, 它可以在其他页面中被导入并引用 co2data 导入示例 :
```{ojs}
import { co2data } from "../ObservableJS.qmd";
Inputs.table(transpose(co2data))
...
```