The V Lab
适合读者希望制作浏览器互动图表的 R 用户
内容范围18 章 · 23 个互动图表
学习方式示例可运行,无需 API 密钥或外部数据

1 如何使用本指南

本指南通过完整示例介绍 R 的 Plotly 包,所有执行示例都使用 R 或 ggplot2 自带的数据,并直接在 R Markdown 中生成互动图表。最终 HTML 是自包含文件:代码、数据、Plotly JavaScript 运行库与图形规格都封装在同一个文件中。

即使没有服务器,Plotly 图表也支持多种浏览器端操作:

  1. 悬停查看精确数值;
  2. 拖动缩放或框选观察;
  3. 双击重置坐标轴;
  4. 单击图例隐藏或显示 trace;
  5. 使用工具栏平移、缩放、自动调整范围或下载浏览器渲染的图片。

只有在互动能回答真实问题时,Plotly 才最有价值:这个点是谁?这个时间窗口发生了什么?这些组有何差异? 互动应该揭示信息,而不是补救本身就不清楚的图表。

1.1 所需包与数据

请在 Rmd 文件外安装依赖:

install.packages(c(
  "plotly", "ggplot2", "htmlwidgets", "crosstalk", "plotlyGeoAssets"
))
数据集 内容 本指南中的用途
mtcars 32 款汽车的性能与设计参数 散点、气泡、悬停、联动
iris 三种鸢尾花的测量数据 映射、箱线图、小提琴图、分面
AirPassengers 1949–1960 年月度航空旅客数 时间序列、控件、组合图
EuStockMarkets 四个欧洲股票指数 多组折线 trace
Titanic 四维列联表中的乘客数 堆叠柱状图
volcano 高程矩阵 热图与三维表面
dim(cars)
#> [1] 32 12
head(cars[, c("model", "mpg", "wt", "hp", "cyl")], 4)

2 Plotly 的图形模型

一个 Plotly figure 有四个关键部分:

  • data:一个或多个 trace,例如 scatter、bar、box、heatmap 或 surface;
  • layout:标题、坐标轴、图例、注释、形状、边距与相机设置;
  • config:不属于分析图形规格的工具栏与浏览器行为;
  • frames:动画使用的可选状态序列。

Trace 是一组具有相同类型、样式规则、图例项和悬停行为的图形标记。一张图可以包含多个 trace。x = ~wt 这样的 R 公式告诉 Plotly 在指定 data 数据框中寻找 wt 列。

model_demo <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  type = "scatter",
  mode = "markers"
)

built_demo <- plotly_build(model_demo)
names(built_demo$x)
#>  [1] "visdat"      "cur_data"    "attrs"       "layout"      "source"      "config"     
#>  [7] "data"        "highlight"   "shinyEvents" "base_url"
length(built_demo$x$data)
#> [1] 1

plotly_build() 可以检查公式、映射、默认值和 trace 拆分全部解析后的最终结构。需要排查传给 Plotly.js 的确切 JSON 时,可使用 plotly_json()。

3 第一个 plot_ly() 图表

教学和生产代码中,建议显式写出 type 与 mode。这里 type = "scatter" 选择散点 trace 系列,mode = "markers" 表示绘制点而不是连线。

p_first <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  text = ~model,
  type = "scatter",
  mode = "markers",
  marker = list(size = 10, color = "#0b6b66", opacity = 0.76),
  hovertemplate = paste(
    "<b>%{text}</b>",
    "重量:%{x:.2f}(千磅)",
    "燃油效率:%{y:.1f} mpg",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "较轻的汽车通常更省油"),
    xaxis = c(list(title = "重量(千磅)"), axis_clean),
    yaxis = c(list(title = "燃油效率(mpg)"), axis_clean),
    hovermode = "closest"
  ) |>
  finish_plotly()

p_first

互动图 1。 将鼠标移到点上查看车型名称,拖动矩形放大,双击恢复。

最后的空标签 <extra></extra> 会移除悬停框中额外的 trace 名称区域。即使存在悬停提示,标题和轴标签依然不可省略,因为读者应该在操作图表前理解它。

4 散点 trace 与视觉映射

4.1 颜色与符号映射

color = ~Species、symbol = ~Species、size = ~hp 与 split = ~group 会把变量映射到视觉属性,并经常按变量拆分 trace。用 I() 包裹字面值,表示固定视觉属性,而不是数据映射。

p_mapping <- plot_ly(
  iris,
  x = ~Sepal.Length,
  y = ~Petal.Length,
  color = ~Species,
  symbol = ~Species,
  colors = c("#0b6b66", "#a4422f", "#3d7085"),
  symbols = c("circle", "diamond", "square"),
  type = "scatter",
  mode = "markers",
  marker = list(size = 9, opacity = 0.72),
  hovertemplate = paste(
    "物种:%{fullData.name}",
    "萼片长度:%{x:.1f}",
    "花瓣长度:%{y:.1f}",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "颜色与符号双重编码让物种更易区分"),
    xaxis = c(list(title = "萼片长度(cm)"), axis_clean),
    yaxis = c(list(title = "花瓣长度(cm)"), axis_clean),
    legend = list(orientation = "h", x = 0, y = -0.2)
  ) |>
  finish_plotly()

p_mapping

互动图 2。 物种同时由颜色和符号编码,因此分组信息不只依赖颜色。

4.2 气泡大小

面积比位置更难精确比较。气泡大小适合表达近似的第三变量;应限制合理的大小范围,并在悬停提示中保留精确值。

p_bubble <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  size = ~hp,
  sizes = c(9, 38),
  customdata = ~hp,
  color = ~factor(cyl),
  colors = c("#3d7085", "#8a5a10", "#a4422f"),
  text = ~model,
  type = "scatter",
  mode = "markers",
  marker = list(opacity = 0.66, line = list(color = "white", width = 1)),
  hovertemplate = paste(
    "<b>%{text}</b>",
    "重量:%{x:.2f}",
    "MPG:%{y:.1f}",
    "马力:%{customdata:.0f}",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "气泡大小表示马力"),
    xaxis = c(list(title = "重量(千磅)"), axis_clean),
    yaxis = c(list(title = "燃油效率(mpg)"), axis_clean),
    legend = list(title = list(text = "汽缸数"))
  ) |>
  finish_plotly()

p_bubble

互动图 3。 位置承担主要比较,气泡面积补充马力,颜色补充汽缸数。

固定颜色可在高层映射参数中使用 color = I(“#0b6b66”),或使用 marker = list(color = “#0b6b66”)。直接写 color = “#0b6b66” 可能被当作标度输入,而不是预期的常量。

5 使用 add_*() 叠加 trace

add_trace() 是通用接口;add_markers()、add_lines()、add_bars()、add_segments() 与 add_text() 等辅助函数能更清楚地表达意图。每层既可以继承初始数据和映射,也可以使用不同数据,或设 inherit = FALSE 后完整指定自身参数。

5.1 观察值与拟合线

fit <- lm(mpg ~ wt, data = cars)
fit_line <- data.frame(wt = seq(min(cars$wt), max(cars$wt), length.out = 100))
fit_line$mpg <- predict(fit, newdata = fit_line)

p_layered <- plot_ly() |>
  add_markers(
    data = cars,
    x = ~wt,
    y = ~mpg,
    text = ~model,
    name = "汽车",
    marker = list(size = 9, color = "#3d7085", opacity = 0.68),
    hovertemplate = "<b>%{text}</b><br>重量:%{x:.2f}<br>MPG:%{y:.1f}<extra></extra>",
    inherit = FALSE
  ) |>
  add_lines(
    data = fit_line,
    x = ~wt,
    y = ~mpg,
    name = "线性拟合",
    line = list(color = "#a4422f", width = 3),
    hovertemplate = "重量:%{x:.2f}<br>拟合 MPG:%{y:.1f}<extra></extra>",
    inherit = FALSE
  ) |>
  plotly::layout(
    title = list(text = "不同 trace 可以使用不同数据"),
    xaxis = c(list(title = "重量(千磅)"), axis_clean),
    yaxis = c(list(title = "燃油效率(mpg)"), axis_clean)
  ) |>
  finish_plotly()

p_layered

互动图 4。 原始观察与模型预测是两个 trace,各有独立悬停模板。

5.2 一个数据框中的多个 trace

wave <- data.frame(x = seq(0, 2 * pi, length.out = 240))
wave$sin_x <- sin(wave$x)
wave$cos_x <- cos(wave$x)

p_wave <- plot_ly(wave, x = ~x) |>
  add_lines(y = ~sin_x, name = "sin(x)", line = list(color = "#0b6b66", width = 3)) |>
  add_lines(y = ~cos_x, name = "cos(x)", line = list(color = "#a4422f", width = 3, dash = "dot")) |>
  plotly::layout(
    title = list(text = "图例项对应 trace"),
    xaxis = c(list(title = "x"), axis_clean),
    yaxis = c(list(title = "函数值", range = c(-1.1, 1.1)), axis_clean),
    legend = list(orientation = "h", x = 0, y = -0.2)
  ) |>
  finish_plotly()

p_wave

互动图 5。 单击图例隐藏一条函数曲线;双击图例只保留该 trace。

6 线图、日期与时间序列

线图编码有顺序的路径。绘图前应在每个组内按 x 排序。按 x 顺序连接的序列使用 add_lines();只有当数据行顺序本身表示轨迹时才使用 add_paths()。

6.1 日期、范围按钮与滑块

air <- data.frame(
  date = seq(as.Date("1949-01-01"), by = "month", length.out = length(AirPassengers)),
  passengers = as.numeric(AirPassengers)
)

p_air <- plot_ly(
  air,
  x = ~date,
  y = ~passengers,
  type = "scatter",
  mode = "lines",
  line = list(color = "#0b6b66", width = 2),
  hovertemplate = "%{x|%Y 年 %m 月}<br>%{y:,.0f} 千人<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "1949–1960 年月度航空旅客数"),
    xaxis = c(
      list(
        title = NULL,
        rangeslider = list(visible = TRUE, thickness = 0.09),
        rangeselector = list(
          buttons = list(
            list(count = 12, label = "1 年", step = "month", stepmode = "backward"),
            list(count = 36, label = "3 年", step = "month", stepmode = "backward"),
            list(step = "all", label = "全部")
          )
        )
      ),
      axis_clean
    ),
    yaxis = c(list(title = "旅客数(千人)"), axis_clean),
    margin = list(t = 70, b = 70)
  ) |>
  finish_plotly()

p_air

互动图 6。 使用按钮或底部范围滑块检查更短的时间窗口。

6.2 用 split 绘制多组序列

stock_matrix <- as.matrix(EuStockMarkets)
stocks <- data.frame(
  observation = rep(seq_len(nrow(stock_matrix)), times = ncol(stock_matrix)),
  value = as.vector(stock_matrix),
  market = rep(colnames(stock_matrix), each = nrow(stock_matrix))
)

p_stocks <- plot_ly(
  stocks,
  x = ~observation,
  y = ~value,
  split = ~market,
  color = ~market,
  colors = c("#0b6b66", "#a4422f", "#3d7085", "#76618c"),
  type = "scatter",
  mode = "lines",
  line = list(width = 1.4),
  hovertemplate = "%{fullData.name}<br>观察序号:%{x}<br>指数:%{y:,.1f}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "split 为每个市场建立一个 trace"),
    xaxis = c(list(title = "交易日观察序号"), axis_clean),
    yaxis = c(list(title = "指数水平"), axis_clean),
    hovermode = "x unified"
  ) |>
  finish_plotly()

p_stocks

互动图 7。 统一悬停框可在同一 x 位置比较四个指数。

7 柱状图与分类比较

Plotly 的柱状 trace 直接绘制提供的数值。与 ggplot2 的 geom_bar() 不同,add_bars() 不会自动计数。应先汇总数据,再选择与问题一致的柱状排列方式。

7.1 并列柱状图

gear_cyl <- as.data.frame(table(
  Cylinders = factor(cars$cyl),
  Gears = factor(cars$gear)
))

p_grouped_bar <- plot_ly(
  gear_cyl,
  x = ~Cylinders,
  y = ~Freq,
  color = ~Gears,
  colors = c("#0b6b66", "#8a5a10", "#a4422f"),
  type = "bar",
  hovertemplate = "汽缸数:%{x}<br>汽车数:%{y}<extra>%{fullData.name} 挡</extra>"
) |>
  plotly::layout(
    title = list(text = "并列柱状图比较绝对数量"),
    xaxis = list(title = "汽缸数"),
    yaxis = c(list(title = "汽车数量", rangemode = "tozero"), axis_clean),
    barmode = "group",
    bargap = 0.18,
    legend = list(title = list(text = "挡位"))
  ) |>
  finish_plotly()

p_grouped_bar

互动图 8。 并列柱让每个汽缸组内的挡位数量更容易比较。

7.2 堆叠柱状图

titanic_counts <- aggregate(
  Freq ~ Class + Survived,
  data = as.data.frame(Titanic),
  FUN = sum
)

p_stacked_bar <- plot_ly(
  titanic_counts,
  x = ~Class,
  y = ~Freq,
  color = ~Survived,
  colors = c("#a4422f", "#0b6b66"),
  type = "bar",
  hovertemplate = "%{x}<br>乘客数:%{y}<extra>生还:%{fullData.name}</extra>"
) |>
  plotly::layout(
    title = list(text = "堆叠柱同时展示总量与构成"),
    xaxis = list(title = "乘客舱级"),
    yaxis = c(list(title = "乘客数量"), axis_clean),
    barmode = "stack",
    legend = list(title = list(text = "生还"))
  ) |>
  finish_plotly()

p_stacked_bar

互动图 9。 堆叠保留总量,但只有共享基线的区段容易准确比较。

barmode = "relative" 可让正负柱向相反方向延伸。Plotly 不会自动把堆叠柱变成 100% 柱;需要先计算比例再绘图。

8 分布与统计 trace

8.1 直方图与分箱

p_histogram <- plot_ly(
  faithful,
  x = ~waiting,
  type = "histogram",
  nbinsx = 20,
  marker = list(color = "#3d7085", line = list(color = "white", width = 1)),
  hovertemplate = "等待时间箱:%{x}<br>观察数:%{y}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "老忠实泉喷发等待时间"),
    xaxis = c(list(title = "等待时间(分钟)"), axis_clean),
    yaxis = c(list(title = "频数"), axis_clean),
    bargap = 0.04
  ) |>
  finish_plotly()

p_histogram

互动图 10。 直方图可以互动,但分析结论仍可能随分箱选择而改变。

8.2 箱线图与离群点

p_box <- plot_ly(
  iris,
  x = ~Species,
  y = ~Sepal.Width,
  color = ~Species,
  colors = c("#0b6b66", "#a4422f", "#3d7085"),
  type = "box",
  boxpoints = "outliers",
  jitter = 0.25,
  pointpos = 0,
  hovertemplate = "%{fullData.name}<br>萼片宽度:%{y:.1f} cm<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "箱线图汇总各组分布"),
    xaxis = list(title = NULL),
    yaxis = c(list(title = "萼片宽度(cm)"), axis_clean),
    showlegend = FALSE
  ) |>
  finish_plotly()

p_box

互动图 11。 悬停可读取离群点的精确值;离群点并不自动等于错误数据。

8.3 小提琴图

p_violin <- plot_ly(
  iris,
  x = ~Species,
  y = ~Petal.Length,
  color = ~Species,
  colors = c("#0b6b66", "#a4422f", "#3d7085"),
  type = "violin",
  box = list(visible = TRUE),
  meanline = list(visible = TRUE),
  points = FALSE,
  hoveron = "violins+points",
  hovertemplate = "%{fullData.name}<br>花瓣长度:%{y:.1f} cm<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "小提琴展示形状,箱线展示稳健摘要"),
    xaxis = list(title = NULL),
    yaxis = c(list(title = "花瓣长度(cm)"), axis_clean),
    showlegend = FALSE
  ) |>
  finish_plotly()

p_violin

互动图 12。 密度宽度来自估计;解释细小凸起前应检查样本量。

9 矩阵、热图与三维表面

矩阵类 trace 接受按行列排列的 z 值。热图通常比三维表面更适合精确比较;只有当旋转和形状探索确实增加信息时才应使用三维图。

9.1 热图

p_heatmap <- plot_ly(
  x = seq_len(ncol(volcano)),
  y = seq_len(nrow(volcano)),
  z = volcano,
  type = "heatmap",
  colors = colorRamp(c("#e9f5f1", "#3d7085", "#8a5a10", "#a4422f")),
  colorbar = list(title = "高程"),
  hovertemplate = "列:%{x}<br>行:%{y}<br>高程:%{z}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "volcano 矩阵热图"),
    xaxis = list(title = "网格列", constrain = "domain"),
    yaxis = list(title = "网格行", scaleanchor = "x")
  ) |>
  finish_plotly()

p_heatmap

互动图 13。 悬停读取单元格;相等轴比例保留网格几何形状。

9.2 三维表面

p_surface <- plot_ly(
  z = volcano,
  type = "surface",
  colors = colorRamp(c("#e9f5f1", "#3d7085", "#8a5a10", "#a4422f")),
  colorbar = list(title = "高程"),
  hovertemplate = "x:%{x}<br>y:%{y}<br>z:%{z}<extra></extra>"
) |>
  plotly::layout(
    title = list(text = "旋转三维表面探索地形"),
    scene = list(
      xaxis = list(title = "网格 x"),
      yaxis = list(title = "网格 y"),
      zaxis = list(title = "高程"),
      aspectmode = "data",
      camera = list(eye = list(x = 1.45, y = 1.35, z = 0.85))
    ),
    margin = list(l = 10, r = 10, b = 10, t = 70)
  ) |>
  finish_plotly()

p_surface

互动图 14。 拖动旋转,滚轮缩放,双击恢复相机。

透视和遮挡会让三维数值难以比较。精确比较很重要时,请同时提供热图、等高线图或数据表。

10 使用 ggplotly() 让 ggplot2 互动化

ggplotly() 会把现有 ggplot 对象转换为 Plotly trace。如果已经有一张完善的静态 ggplot,这是最快的起点。但转换不是逐像素复制:不支持的 geom、主题细节、注释和图例行为可能发生变化。

10.1 转换散点图

g_scatter <- ggplot(
  cars,
  aes(
    wt,
    mpg,
    color = factor(cyl),
    text = paste0(
      "<b>", model, "</b>",
      "<br>重量:", format(wt, digits = 3),
      "<br>MPG:", mpg,
      "<br>马力:", hp
    )
  )
) +
  geom_point(size = 3, alpha = 0.72) +
  scale_color_manual(values = c("4" = "#3d7085", "6" = "#8a5a10", "8" = "#a4422f")) +
  labs(
    title = "ggplot 可以转换为互动图",
    x = "重量(千磅)",
    y = "燃油效率(mpg)",
    color = "汽缸数"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", panel.grid.minor = element_blank())

p_ggplotly <- ggplotly(g_scatter, tooltip = "text") |>
  finish_plotly()

p_ggplotly

互动图 15。 text 映射提供经过筛选的悬停内容,不会暴露全部映射变量。

10.2 分面与指定提示内容

g_facet <- ggplot(
  iris,
  aes(
    Sepal.Length,
    Petal.Length,
    text = paste0(
      "物种:", Species,
      "<br>萼片长度:", Sepal.Length,
      "<br>花瓣长度:", Petal.Length
    )
  )
) +
  geom_point(color = "#0b6b66", alpha = 0.7) +
  facet_wrap(~Species, nrow = 1) +
  labs(
    title = "分面会转换成 Plotly 子图",
    x = "萼片长度(cm)",
    y = "花瓣长度(cm)"
  ) +
  theme_minimal(base_size = 11) +
  theme(panel.grid.minor = element_blank())

p_ggfacet <- ggplotly(g_facet, tooltip = "text") |>
  plotly::layout(hovermode = "closest") |>
  finish_plotly()

p_ggfacet

互动图 16。 常用 geom 的分面通常转换良好,但发布前仍需检查每个面板与图例。

11 布局、坐标轴、图例、注释与控件

layout() 接收与 Plotly.js 属性对应的嵌套列表,用于控制分析图形的呈现;config() 控制工具栏与浏览器行为。区分二者能让可复用函数更容易理解。

peak_row <- air[which.max(air$passengers), ]

p_layout <- plot_ly(
  air,
  x = ~date,
  y = ~passengers,
  type = "scatter",
  mode = "lines+markers",
  line = list(color = "#3d7085", width = 2),
  marker = list(size = 4),
  hovertemplate = "%{x|%Y 年 %m 月}<br>%{y:,.0f} 千人<extra></extra>"
) |>
  plotly::layout(
    title = list(
      text = "航空旅行增长的同时,季节波动也在扩大<br><sup>月度国际航空旅客数</sup>",
      x = 0,
      xanchor = "left"
    ),
    xaxis = c(list(title = NULL, tickformat = "%Y"), axis_clean),
    yaxis = c(list(title = "旅客数(千人)", rangemode = "tozero"), axis_clean),
    hovermode = "x unified",
    annotations = list(list(
      x = peak_row$date,
      y = peak_row$passengers,
      text = "序列最大值",
      showarrow = TRUE,
      arrowcolor = "#a4422f",
      ax = -55,
      ay = -45
    )),
    shapes = list(list(
      type = "line",
      x0 = min(air$date),
      x1 = max(air$date),
      y0 = mean(air$passengers),
      y1 = mean(air$passengers),
      line = list(color = "#a4422f", dash = "dash", width = 1.5)
    )),
    margin = list(l = 70, r = 25, b = 55, t = 90)
  ) |>
  plotly::config(
    displaylogo = FALSE,
    responsive = TRUE,
    scrollZoom = FALSE,
    modeBarButtonsToRemove = c("lasso2d")
  )

p_layout

互动图 17。 布局加入副标题、注释、均值参考线、统一悬停与明确边距。

常用布局模式包括:

任务 属性
使用对数轴 yaxis = list(type = "log")
固定可见范围 xaxis = list(range = c(min, max))
保持 x/y 单位相同 yaxis = list(scaleanchor = "x")
使用水平图例 legend = list(orientation = "h")
在一个 x 位置比较 trace hovermode = "x unified"
绘制参考区域 shapes = list(list(type = "rect", ...))

12 使用 hovertemplate 与 customdata 精确控制悬停

hovertemplate 可以精确控制内容与格式。Plotly 变量使用 %{...} 占位符和 D3 风格格式。额外信息通过 text 或 customdata 提供;不要把所有列都塞进每个提示框。

hover_matrix <- cbind(cars$hp, cars$qsec)

p_hover <- plot_ly(
  cars,
  x = ~wt,
  y = ~mpg,
  text = ~model,
  customdata = hover_matrix,
  type = "scatter",
  mode = "markers",
  marker = list(size = 10, color = "#0b6b66", opacity = 0.75),
  hovertemplate = paste(
    "<b>%{text}</b>",
    "重量:%{x:.2f}(千磅)",
    "燃油效率:%{y:.1f} mpg",
    "马力:%{customdata[0]:.0f}",
    "四分之一英里:%{customdata[1]:.2f} 秒",
    "<extra></extra>",
    sep = "<br>"
  )
) |>
  plotly::layout(
    title = list(text = "customdata 将元数据附着到每个点"),
    xaxis = c(list(title = "重量(千磅)"), axis_clean),
    yaxis = c(list(title = "燃油效率(mpg)"), axis_clean)
  ) |>
  finish_plotly()

p_hover

互动图 18。 customdata[0] 和 [1] 采用 JavaScript 从 0 开始的索引。

如果 customdata 混合不同 R 类型,cbind() 可能把所有内容转成字符。需要数值格式时,应保持自定义数据矩阵为数值型,或提前构造明确的数据结构。

13 使用 subplot() 与 Crosstalk 协调视图

13.1 共享 x 轴的上下组合图

air$change <- c(NA, diff(air$passengers))
air_change <- air[!is.na(air$change), ]

p_level <- plot_ly(
  air,
  x = ~date,
  y = ~passengers,
  type = "scatter",
  mode = "lines",
  line = list(color = "#0b6b66", width = 2),
  name = "水平",
  hovertemplate = "%{x|%Y 年 %m 月}<br>水平:%{y:,.0f}<extra></extra>"
)

p_change <- plot_ly(
  air_change,
  x = ~date,
  y = ~change,
  type = "bar",
  marker = list(color = ifelse(air_change$change >= 0, "#3d7085", "#a4422f")),
  name = "月度变化",
  hovertemplate = "%{x|%Y 年 %m 月}<br>变化:%{y:+,.0f}<extra></extra>"
)

p_subplot <- subplot(
  p_level,
  p_change,
  nrows = 2,
  heights = c(0.64, 0.36),
  shareX = TRUE,
  titleX = FALSE,
  margin = 0.055
) |>
  plotly::layout(
    title = list(text = "序列水平与环比变化"),
    showlegend = FALSE,
    hovermode = "x unified"
  ) |>
  finish_plotly()

p_subplot

互动图 19。 共享 x 轴让序列水平与月度变化精确对齐。

subplot() 可设置 widths、heights、nrows、shareX 与 shareY。较小边距能节省空间,但必须为刻度和标题留足位置。组合时不同布局会被合并,完成后应检查结果。

13.2 浏览器端联动选择

Crosstalk 可以在静态 HTML 中联动 Plotly 视图。highlight_key() 附加稳定键,highlight() 定义浏览器选择后如何淡化和突出观察。该联动不需要 Shiny 服务器。

shared_cars <- highlight_key(cars, ~model, group = "cars-link")

p_weight <- plot_ly(
  shared_cars,
  x = ~wt,
  y = ~mpg,
  type = "scatter",
  mode = "markers",
  marker = list(size = 9, color = "#3d7085", opacity = 0.75),
  text = ~model,
  hovertemplate = "%{text}<br>重量:%{x:.2f}<br>MPG:%{y:.1f}<extra></extra>"
)

p_power <- plot_ly(
  shared_cars,
  x = ~hp,
  y = ~qsec,
  type = "scatter",
  mode = "markers",
  marker = list(size = 9, color = "#8a5a10", opacity = 0.75),
  text = ~model,
  hovertemplate = "%{text}<br>马力:%{x}<br>四分之一英里:%{y:.2f} 秒<extra></extra>"
)

p_linked <- subplot(p_weight, p_power, margin = 0.08, titleX = TRUE, titleY = TRUE) |>
  plotly::layout(
    title = list(text = "在任一面板拖动框选"),
    dragmode = "select",
    showlegend = FALSE
  ) |>
  highlight(
    on = "plotly_selected",
    persistent = TRUE,
    dynamic = FALSE,
    color = "#a4422f",
    opacityDim = 0.18
  ) |>
  finish_plotly()

p_linked

互动图 20。 在一个面板框选,即可在两个面板突出相同车型;双击清除。

14 使用 frame 与稳定 ids 创建动画

动画应该展示有顺序状态中的有意义变化。稳定的字符串 ids 告诉 Plotly 哪个对象在不同 frame 中持续存在,从而获得更平滑的过渡并避免对象身份交换。

animation_data <- do.call(
  rbind,
  lapply(seq_len(10), function(frame_no) {
    id <- seq_len(8)
    angle <- 2 * pi * (id - 1) / 8 + (frame_no - 1) * pi / 20
    data.frame(
      id = paste0("point-", id),
      frame = sprintf("%02d", frame_no),
      x = cos(angle),
      y = sin(angle),
      value = id
    )
  })
)

p_animation <- plot_ly(
  animation_data,
  x = ~x,
  y = ~y,
  frame = ~frame,
  ids = ~id,
  color = ~factor(value),
  colors = colorRamp(c("#0b6b66", "#3d7085", "#a4422f")),
  type = "scatter",
  mode = "markers+text",
  text = ~value,
  textposition = "middle center",
  marker = list(size = 34, line = list(color = "white", width = 1)),
  hovertemplate = "对象 %{text}<extra></extra>",
  showlegend = FALSE
) |>
  animation_opts(frame = 650, transition = 320, easing = "cubic-in-out", redraw = FALSE) |>
  animation_slider(currentvalue = list(prefix = "帧:")) |>
  animation_button(label = "播放") |>
  plotly::layout(
    title = list(text = "稳定 ID 在各帧中保留对象身份"),
    xaxis = list(title = NULL, range = c(-1.35, 1.35), visible = FALSE),
    yaxis = list(title = NULL, range = c(-1.35, 1.35), visible = FALSE, scaleanchor = "x"),
    margin = list(l = 20, r = 20, b = 80, t = 70)
  ) |>
  finish_plotly()

p_animation

互动图 21。 播放按钮和滑块控制 frame,每个编号对象保持相同 ID。

即使动画削弱比较,它仍可能吸引注意。当读者需要精确比较状态时,小多图或静态前后对照通常更好。

15 使用 plot_geo() 创建地理图

plot_geo() 无需 Mapbox token 即可创建地理 trace。下例使用 R 自带的州缩写,offline = TRUE 则让配套包 plotlyGeoAssets 提供本地地图资源;完整渲染本教程需要安装该包。

if (!requireNamespace("plotlyGeoAssets", quietly = TRUE)) {
  stop("请安装 plotlyGeoAssets 后再渲染离线地图示例。")
}

states <- data.frame(
  state = state.name,
  code = state.abb,
  income = state.x77[, "Income"],
  population = state.x77[, "Population"]
)

p_geo <- plot_geo(states, offline = TRUE) |>
  add_trace(
    z = ~income,
    locations = ~code,
    locationmode = "USA-states",
    type = "choropleth",
    text = ~state,
    customdata = ~population,
    colors = "Blues",
    colorbar = list(title = "人均收入"),
    marker = list(line = list(color = "white", width = 0.6)),
    hovertemplate = paste(
      "<b>%{text}</b>",
      "人均收入(1974 年美元):%{z:,.0f}",
      "人口(千人):%{customdata:,.0f}",
      "<extra></extra>",
      sep = "<br>"
    )
  ) |>
  plotly::layout(
    title = list(text = "1974 年美国各州人均收入"),
    geo = list(
      scope = "usa",
      projection = list(type = "albers usa"),
      showlakes = TRUE,
      lakecolor = "white",
      bgcolor = "rgba(0,0,0,0)"
    ),
    margin = list(l = 0, r = 0, b = 0, t = 70)
  ) |>
  finish_plotly()

p_geo

互动图 22。 该分级着色地图使用本地州代码和地图资源,不需要 API 密钥。

分级着色地图会强调面积较大的区域,而不只是数值。暴露人口不同的情况下应使用率而不是原始计数,并选择合理分级或连续色标;排名很重要时还应提供非地图对照。

16 事件与 Shiny 集成

静态 HTML 可以在浏览器中缩放、悬停、选择、播放动画、切换 trace 和执行 Crosstalk 联动,但发布后不能运行新的 R 计算。服务器端事件处理需要 Shiny 或其他应用运行环境。

关键组件包括:

  • source:为需要读取事件的 widget 命名;
  • key:保留有意义的观察标识;
  • event_data():在响应式上下文中读取单击、选择、悬停或布局事件;
  • plotlyProxy():无需重建全部界面即可更新现有 widget。
library(shiny)
library(plotly)

cars <- data.frame(model = rownames(mtcars), mtcars, row.names = NULL)

ui <- fluidPage(
  plotlyOutput("cars_plot"),
  verbatimTextOutput("clicked")
)

server <- function(input, output, session) {
  output$cars_plot <- renderPlotly({
    plot_ly(
      cars,
      x = ~wt,
      y = ~mpg,
      key = ~model,
      source = "cars",
      type = "scatter",
      mode = "markers"
    )
  })

  output$clicked <- renderPrint({
    event_data("plotly_click", source = "cars")
  })
}

shinyApp(ui, server)

常见事件名包括 plotly_click、plotly_hover、plotly_selected、plotly_brushing、plotly_relayout 和 plotly_legendclick。较少见事件可按需使用 event_register() 注册。

在普通静态 Rmd 中调用 event_data() 并不会让发布页面具备 R 响应能力;它必须在 Shiny 响应式上下文中运行。静态页面需要联动时,应使用 Crosstalk 支持的浏览器端功能。

17 保存、打包与发布 HTML

17.1 保存单个 widget

htmlwidgets::saveWidget() 可以脱离 R Markdown 保存 Plotly widget。其参数拼写为 selfcontained,而 R Markdown YAML 使用 self_contained。

htmlwidgets::saveWidget(
  p_first,
  file = "car_scatter.html",
  selfcontained = TRUE,
  title = "互动汽车散点图"
)

如果使用 selfcontained = FALSE,HTML 会依赖 car_scatter_files/ 一类伴随目录;发布时必须一起上传并保持相对路径。

17.2 缩减 JavaScript 包

partial_bundle() 可以为单个 widget 用较小模块替换完整 Plotly.js:

small_widget <- partial_bundle(p_first, type = "auto", local = TRUE)
htmlwidgets::saveWidget(small_widget, "car_scatter_small.html", selfcontained = TRUE)

不要在同一页混用互不兼容的 partial bundle:先加载的 Plotly.js 可能被后续 widget 复用,却缺少后者需要的 trace 模块。本指南同时包含 scatter、bar、violin、heatmap、surface、animation 和 geo,因此有意使用完整包。

17.3 保存静态图片

可复现的 PNG、SVG、PDF 或 WebP 输出应使用 save_image()。它需要通过 reticulate 配置带 Kaleido 的本地 Python 环境,但不需要 Plotly 云账户。

plotly::save_image(
  p_first,
  file = "car_scatter.png",
  width = 1400,
  height = 900,
  scale = 1
)

浏览器工具栏下载方便读者临时使用;脚本导出则更可复现,因为尺寸与软件版本都有记录。

18 性能、调试与复现

18.1 大量点使用 WebGL

对于适量数据,SVG 散点 trace 有很好的浏览器交互。达到数万点时,可使用 type = "scattergl" 或 toWebGL(),减少悬停内容,并先考虑抽样或分箱,而不是只增加硬件负担。

large_points <- data.frame(
  x = rnorm(20000),
  y = 0.65 * rnorm(20000) + rnorm(20000, sd = 0.55),
  group = sample(c("A", "B"), 20000, replace = TRUE)
)

p_webgl <- plot_ly(
  large_points,
  x = ~x,
  y = ~y,
  color = ~group,
  colors = c("#3d7085", "#a4422f"),
  type = "scattergl",
  mode = "markers",
  marker = list(size = 4, opacity = 0.32),
  hoverinfo = "skip"
) |>
  plotly::layout(
    title = list(text = "WebGL 让两万个点保持流畅"),
    xaxis = c(list(title = "x"), axis_clean),
    yaxis = c(list(title = "y"), axis_clean),
    legend = list(title = list(text = "组"))
  ) |>
  finish_plotly()

p_webgl

互动图 23。 WebGL 提升渲染吞吐量,但汇总有时能更诚实地表达高密度分布。

18.2 调试清单

症状 可能原因 检查或修复方法
找不到数据列 忘记公式语法 指定 data 后使用 x = ~column
固定颜色产生奇怪标度 混淆了常量与映射 使用 I("#hex") 或嵌套 marker/line 列表
折线向后跳 行顺序错误 在每组内按 x 排序后再绘图
图例重复 多个 trace 表示相同语义 对部分 trace 设 showlegend = FALSE,或共用 legendgroup
悬停信息过多 默认提示包含所有映射 使用 hovertemplate 或 ggplotly(..., tooltip = ...)
数值悬停格式失效 customdata 被转成字符 保持自定义数据矩阵为数值型
转换后的 ggplot 不同 并非每个 geom/主题细节都支持转换 检查 ggplotly(),必要时原生构建
动画对象互换身份 缺少稳定字符串 ID 指定 ids = ~stable_id
GitHub Pages 上 event_data() 没反应 静态 HTML 没有 R 服务器 放入 Shiny,或用 Crosstalk 做浏览器端联动
发布后 HTML 损坏 漏传 _files 依赖目录 使用自包含 HTML,或上传伴随目录
打包后后续 trace 消失 同页 partial bundle 不兼容 使用一个兼容包,或完整 Plotly.js
浏览器交互缓慢 SVG 标记或悬停内容过多 使用 scattergl、抽样、分箱或汇总

遇到复杂问题时,先检查图形结构:

built <- plotly_build(p_first)
str(built$x$data, max.level = 2)

plotly_json(p_first, pretty = TRUE)

# 完整 Plotly.js schema 很大,只在需要时查询。
names(schema())

18.3 一套可靠的工作流

  1. 明确分析问题,以及一个标记代表什么分析单位。
  2. 从最小可用 trace 和正确标签开始。
  3. 悬停框只加入识别或比较真正需要的字段。
  4. 只有在回答具体问题时才增加 trace、子图或控件。
  5. 检查图例切换、缩放重置、选择、动画与手机宽度。
  6. 明确产物是静态 HTML、Shiny 应用还是静态图片。
  7. 记录包版本,并保留可复现的数据准备流程。

18.4 版本信息与官方参考

cat("R:", R.version.string, "\n")
#> R: R version 4.6.1 (2026-06-24)
cat("plotly:", as.character(packageVersion("plotly")), "\n")
#> plotly: 4.12.1
cat("ggplot2:", as.character(packageVersion("ggplot2")), "\n")
#> ggplot2: 4.0.3
cat("htmlwidgets:", as.character(packageVersion("htmlwidgets")), "\n")
#> htmlwidgets: 1.6.4
cat("crosstalk:", as.character(packageVersion("crosstalk")), "\n")
#> crosstalk: 1.2.2
cat("knitr:", as.character(packageVersion("knitr")), "\n")
#> knitr: 1.51
cat("rmarkdown:", as.character(packageVersion("rmarkdown")), "\n")
#> rmarkdown: 2.31

至此,你已经完成了从第一个 plot_ly() 散点图到 trace 叠加、ggplot 转换、精确悬停、组合图、联动选择、动画、地图、发布、Shiny 事件与 WebGL 性能优化的完整学习路径。最好的互动图,依然是在读者动手之前就已经把问题和比较任务讲清楚的图。