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 概述

上面的示例用到了标准库,包括 :

  1. Observable stdlib 用于 DOM 操作、文件处理、导入代码等的核心基础原语(primitives)

  2. Observable Inputs 标准输入控件,包括滑块、下拉菜单、表格、复选框等

  3. Observable Plot 用于探索性数据可视化的高层绘图库

这些库有些不用引入, 它们在 Observablehq 的 notebook , Quarto 的 {ojs} 单元格中, 都是自动可用的。 使用其他 JavaScript 库只需显式 import导入

在 OJS 中通常会用到三种类型的库 :

  1. Observable 核心库,上面介绍的在每个文档中都自动可用

  2. 来自 npm 和 ObservableHQ 的第三方 JavaScript 库

  3. 自己创建的自定义库

本文将对核心库进行概述,并给出一些使用第三方库(D3 和 Arquero)的示例。自己的创建库请参见 代码复用

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 在每次点击时更新值 :

viewof clicks = Inputs.button("点击 +1", {value: 0, reduce: value => value + 1, label: "按钮 : "})

Inputs.toggle 生成布尔开关 :

viewof mute = Inputs.toggle({label: "静音 : "})

3.3.2 单选与多选

Inputs.radio 从互斥的选项中选一个 :

viewof colorChoice = Inputs.radio(["red", "green", "blue"], {label: "颜色 : "})

Inputs.checkbox 从列表中选择一个或多个选项 :

viewof flavors = Inputs.checkbox(["咸", "辣", "酸", "鲜"], {label: "口味 : "})

3.3.3 数值输入

Inputs.range 是连续数值滑块 :

viewof intensity = Inputs.range([0, 100], {step: 1, label: "强度 : "})

Inputs.number 与 Inputs.range 类似,但只显示数字输入框 :

viewof age = Inputs.number([0, 120], {step: 1, label: "年龄", placeholder: "0-120"})

3.3.4 选择、搜索与表格

Inputs.select 是下拉选择框 :

viewof size = Inputs.select(["小", "中", "大"], {label: "尺寸"})

Inputs.search 提供带搜索的输入框 :

viewof city = Inputs.search(["北京", "上海", "广州", "深圳", "杭州", "成都"], {label: "城市"})

Inputs.table 从表格中选择一行 :

viewof selectedRow = Inputs.table([
  {name: "Alice", score: 90},
  {name: "Bob", score: 85},
  {name: "Cara", score: 95}
], {multiple: false, label: "选择其中一行"})

还可以选中多行 :

viewof selectedRows = Inputs.table([
  {name: "Alice", score: 90},
  {name: "Bob", score: 85},
  {name: "Cara", score: 95}
], {multiple: true, label: "选择一行或几行"})

3.3.5 文本输入

Inputs.text 是单行文本,Inputs.textarea 是多行文本 :

viewof userName = Inputs.text({label: "姓名", placeholder: "输入你的姓名"})
viewof bio = Inputs.textarea({label: "简介", placeholder: "介绍一下你自己…"})

下面这些控件与 Inputs.text 类似,只是输入类型不同 :

viewof email = Inputs.email({label: "邮箱", placeholder: "name@example.com"})
viewof tel = Inputs.tel({label: "电话", placeholder: "138-0000-0000"})
viewof url = Inputs.url({label: "网址", placeholder: "https://example.com"})
viewof password = Inputs.password({label: "密码"})
viewof colorPick = Inputs.color({label: "颜色"})

3.3.6 日期与时间

Inputs.date 只有日期可以选择, Inputs.datetime 日期和时间都可以选择 :

viewof startDate = Inputs.date({label: "开始日期", value: "2026-01-01"})
viewof startDatetime = Inputs.datetime({label: "开始时间", value: "2026-01-01T09:00"})

3.3.7 文件上传

Inputs.file 用于选择本地文件, 并传给浏览器。 他并不依赖服务器 :

viewof fileInput = Inputs.file({label: "上传一张照片 : ", accept: "image/*", multiple: true})

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 许可证标记 :

D3 notebook 在 ObservableHQ 上的头部。元数据行显示了许可证 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 单元格布局

还可以利用 panel 系列 DIV 组件进行排版 :

Figure 1: 按性别和种类划分的企鹅体重

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))
...
```

9 链接