环形多轨道样本注释图

作者

[编辑] 王诗翔

[贡献] Claude Code —— AI 生成,独立 AI 复核;

修改于

2026-10-19

🤖 AI Skill — Download the Bizard unified skill for your AI assistant ⬇️ Download Skill ZIP
🔌 MCP Server — Let AI agents (Claude Code, Cursor, …) query Bizard live: claude mcp add bizard -- npx @openbiox/bizard-mcp — or the free hosted instance (no install, faster from mainland China): claude mcp add --transport http bizard http://biotree.top:38123/bizard-mcp/mcp 📖 MCP Docs
📖 Cite Bizard — if Bizard helps your research, please cite: Li, K., Zheng, H., Huang, K., et al. (2026). Bizard: A Community-Driven Platform for Accelerating and Enhancing Biomedical Data Visualization. iMetaMed, e70038. 🔗 DOI / BibTeX

环形多轨道样本注释图把队列中的每个样本排成圆环上的一个扇区:每个扇区在外缘有一个样本标签,同心圆环轨道编码不同变量(连续变量以棒棒糖轨道呈现、圆点离圆心的距离表示数值大小,分类变量以彩色色带呈现),圆环缺口处安放队列汇总——每条连续轨道对应一块白色面板(箱线图、蜂群散点与四分位标签),每条分类轨道对应一条 100% 堆叠比例柱,并配有两个连续标尺的紧凑数值轴。每块汇总面板正对它自己的轨道(径向位置镜像到左侧),且所有面板统一高度,使缺口区读起来是一列整齐的统计图;顶部图例按轨道逐行自动生成。它专为需要同时展示大量样本与多变量信息的队列图而设计(参考图展示了 108 名纵向多组学队列参与者),个体层面的模式、群体构成与总体分布可在一瞥之间同读。本教程复现《顶刊图社》系列(第094期,复现自 Shen 等发表于 Nature Aging 2024 的图)介绍的图表类型,见文末参考文献。

示例

CircularAnnotation DEMO

这个示例展示了一个模拟的 108 人队列。样本按年龄沿圆环排序(外缘标签);外侧两条棒棒糖轨道分别展示年龄与 BMI(圆点离圆心的距离 = 数值),内侧三条色带依次编码胰岛素反应、族裔与性别。左上方缺口处:每条连续轨道对应一块白色面板,内含箱线图、蜂群散点与三个四分位的灰色标签;每条分类轨道对应一条高高的 100% 堆叠比例柱,每个分段都标注百分比;两个数值轴(年龄 75 / 51 / 21、BMI 15 / 30 / 41)位于圆环中线位置,顶部图例为每条分类轨道单独占一行。

环境配置

  • 系统要求:跨平台(Linux/MacOS/Windows)

  • 编程语言:R

  • 依赖包:tidyverse;ggbeeswarm

# Installing necessary packages
if (!requireNamespace("tidyverse", quietly = TRUE)) {
  install.packages("tidyverse")
}
if (!requireNamespace("ggbeeswarm", quietly = TRUE)) {
  install.packages("ggbeeswarm")
}

# Load packages
library(tidyverse)
library(ggbeeswarm)

数据准备

示例模拟了一个与参考图规模相当的队列(108 名参与者),使教程无需外部数据即可运行。每行一名参与者;age 与 bmi 为连续变量,iris、ethnicity 与 gender 为分类变量。

set.seed(2026)
n <- 108
dat <- tibble(
  id = sprintf("P%03d", 1:n),
  age = round(pmin(pmax(rnorm(n, 56, 12), 21), 74)),
  bmi = round(pmin(pmax(rnorm(n, 28, 4), 15), 41), 1),
  iris = sample(c("IS", "IR", "Unknown"), n, TRUE, prob = c(.52, .33, .15)),
  ethnicity = sample(c("Asian", "Black", "Caucasian", "Hispanics", "Unknown"), n, TRUE,
                     prob = c(.20, .07, .66, .04, .03)),
  gender = sample(c("Female", "Male"), n, TRUE, prob = c(.52, .48))
)

head(dat)
# A tibble: 6 × 6
  id      age   bmi iris    ethnicity gender
  <chr> <dbl> <dbl> <chr>   <chr>     <chr> 
1 P001     62  23.3 IR      Caucasian Male  
2 P002     43  23.8 IS      Asian     Female
3 P003     58  22.1 Unknown Caucasian Male  
4 P004     55  23.2 IS      Asian     Male  
5 P005     48  26.6 Unknown Caucasian Male  
6 P006     26  30.1 IS      Caucasian Female

轨道布局以声明式方式描述。每条连续轨道定义其列名、占用的半径区间(inner/outer)、映射到该区间的数值范围(min/max)、轨道颜色与可选刻度值。每条分类轨道定义其列名与半径区间;颜色(及顺序)通过命名向量提供,未列出的分类会被自动配色。

numeric_tracks <- tribble(
  ~name,  ~column, ~title,          ~inner, ~outer, ~min, ~max, ~color,
  "Age",  "age",   "Age\n(years)",  3.32,   4.16,    21,   75,   "#702083",
  "BMI",  "bmi",   "BMI",           2.39,   3.23,    15,   41,   "#F04A22")

category_tracks <- tribble(
  ~name,               ~column,      ~title,                   ~inner, ~outer,
  "Insulin response",  "iris",       "Insulin\nresponse (%)",  0.90,   1.32,
  "Ethnicity",         "ethnicity",  "Ethnicity (%)",          1.39,   1.81,
  "Gender",            "gender",     "Gender (%)",             1.88,   2.30)

category_colors <- c(
  Female = "#F33010", Male = "#E2BD6E",
  Asian = "#046C9B", Black = "#A8D3D4", Caucasian = "#E5C39C", Hispanics = "#CA9143",
  Unknown = "#BDBDBD", IS = "#F21901", IR = "#3C99B0")

# Stacking order inside each proportion bar (bottom-to-top) and legend order
bar_order <- list(
  iris = c("Unknown", "IS", "IR"),
  ethnicity = c("Unknown", "Hispanics", "Caucasian", "Black", "Asian"),
  gender = c("Male", "Female"))
legend_order <- list(
  iris = c("IS", "IR"),
  ethnicity = c("Asian", "Black", "Caucasian", "Hispanics"),
  gender = c("Female", "Male"))

# Display names for short category codes, and the tick values of the axes
legend_labels <- c(IS = "Insulin sensitivity", IR = "Insulin resistance")
tick_values <- list(Age = c(21, 51, 75), BMI = c(15, 30, 41))

可视化

完整图形由一个可复用函数 circular_annotation_plot() 一次调用完成。它把每个样本扇区转换为普通笛卡尔坐标(圆环与汇总面板因此处于同一 ggplot2 坐标系中并严格对齐),随后依次叠加分类色带、棒棒糖轨道、样本标签、环形刻度与缺口面板。

circular_annotation_plot <- function(data, id_col, sort_col,
                                     numeric_tracks, category_tracks,
                                     category_order = list(), legend_order = list(),
                                     category_colors = c(), legend_labels = c(),
                                     tick_values = list(), panel_height = 2.95,
                                     gap_degrees = 90, font_scale = 1,
                                     show_summary = TRUE, show_unknown = FALSE) {
  for (column in category_tracks$column) {
    data[[column]] <- coalesce(na_if(as.character(data[[column]]), ""), "Unknown")
  }
  data <- data |>
    arrange(.data[[sort_col]]) |>
    mutate(.i = row_number(),
           .step = (360 - gap_degrees) * pi / 180 / n(),
           .start = pi / 2 - (.i - 1) * .step,
           .end = pi / 2 - .i * .step,
           .angle = (.start + .end) / 2)

  sector_polygons <- function(inner, outer) {
    map_dfr(seq_len(nrow(data)), function(i) {
      a <- seq(data$.start[i], data$.end[i], length.out = 10)
      tibble(.i = i,
             x = c(outer * cos(a), inner * cos(rev(a))),
             y = c(outer * sin(a), inner * sin(rev(a))))
    })
  }
  numeric_rings <- map_dfr(seq_len(nrow(numeric_tracks)), function(i) {
    tb <- numeric_tracks[i, ]
    sector_polygons(tb$inner, tb$outer) |> mutate(.track = tb$name)
  })
  category_rings <- map_dfr(seq_len(nrow(category_tracks)), function(i) {
    tb <- category_tracks[i, ]
    sector_polygons(tb$inner, tb$outer) |>
      mutate(.track = tb$name, value = data[[tb$column]][.i])
  })

  all_values <- unique(category_rings$value)
  missing_values <- setdiff(all_values, names(category_colors))
  if (length(missing_values) > 0) {
    category_colors <- c(category_colors,
      rlang::set_names(scales::hue_pal()(length(missing_values)), missing_values))
  }
  key_colors <- category_rings |>
    distinct(.track, value) |>
    mutate(key = paste(.track, value, sep = "::"), color = unname(category_colors[value]))
  key_pal <- rlang::set_names(key_colors$color, key_colors$key)

  lollipops <- map_dfr(seq_len(nrow(numeric_tracks)), function(i) {
    tb <- numeric_tracks[i, ]
    r <- scales::rescale(data[[tb$column]], to = c(tb$inner, tb$outer), from = c(tb$min, tb$max))
    tibble(.track = tb$name,
           x0 = tb$inner * cos(data$.angle), y0 = tb$inner * sin(data$.angle),
           x1 = r * cos(data$.angle), y1 = r * sin(data$.angle))
  })

  # gap summary: numeric points/quartiles
  numeric_points <- map_dfr(seq_len(nrow(numeric_tracks)), function(i) {
    tb <- numeric_tracks[i, ]
    tibble(.track = tb$name, x = -(tb$inner + tb$outer) / 2,
           y = scales::rescale(data[[tb$column]], to = c(0, panel_height), from = c(tb$min, tb$max)))
  })
  quartiles <- map_dfr(seq_len(nrow(numeric_tracks)), function(i) {
    tb <- numeric_tracks[i, ]
    q <- quantile(data[[tb$column]], c(.25, .5, .75))
    tibble(x = rep(-(tb$inner + tb$outer) / 2, 3),
           y = scales::rescale(q, to = c(0, panel_height), from = c(tb$min, tb$max)),
           label = sprintf("%.1f", q))
  })
  panel_boxes <- numeric_tracks |>
    transmute(xmin = -outer, xmax = -inner, ymin = 0, ymax = panel_height, .track = name)

  bars <- map_dfr(seq_len(nrow(category_tracks)), function(i) {
    tb <- category_tracks[i, ]
    observed <- unique(data[[tb$column]])
    preferred <- category_order[[tb$column]]
    if (is.null(preferred)) preferred <- character()
    lv <- c(preferred[preferred %in% observed], sort(setdiff(observed, preferred)))
    tibble(value = factor(data[[tb$column]], levels = lv)) |>
      count(value, .drop = FALSE) |>
      mutate(fraction = n / sum(n),
             ymin = lag(cumsum(fraction * panel_height), default = 0),
             ymax = cumsum(fraction * panel_height),
             xmin = -tb$outer, xmax = -tb$inner,
             x = -(tb$inner + tb$outer) / 2, y = (ymin + ymax) / 2,
             .track = tb$name, key = paste(.track, value, sep = "::"))
  })

  outer_radius <- max(numeric_tracks$outer, category_tracks$outer) + 0.21
  sample_labels <- data |>
    mutate(x = outer_radius * cos(.angle), y = outer_radius * sin(.angle),
           text_angle = .angle * 180 / pi,
           flip = text_angle < -90,
           text_angle = if_else(flip, text_angle + 180, text_angle),
           hjust = if_else(flip, 1, 0))

  panel_titles <- bind_rows(
    numeric_tracks |> transmute(x = -(inner + outer) / 2, y = panel_height + .22,
                                label = title, angle = 0, hjust = .5),
    if (show_summary) category_tracks |> transmute(x = -outer, y = panel_height + .10,
                                label = title, angle = 50, hjust = 0))

  axis_labels <- map_dfr(seq_len(nrow(numeric_tracks)), function(i) {
    tb <- numeric_tracks[i, ]
    v <- tick_values[[tb$name]]
    if (is.null(v)) v <- round(seq(tb$min, tb$max, length.out = 3))
    tibble(label = v, y = scales::rescale(v, to = c(tb$inner, tb$outer), from = c(tb$min, tb$max)))
  })
  axis_lines <- numeric_tracks |> transmute(x = 0, xend = 0, y = inner, yend = outer)

  # legend table: one row per category track (header + items + spacers)
  legend_items <- map_dfr(seq_len(nrow(category_tracks)), function(i) {
    tb <- category_tracks[i, ]
    observed <- unique(data[[tb$column]])
    preferred <- legend_order[[tb$column]]
    if (is.null(preferred)) preferred <- character()
    ord <- c(preferred[preferred %in% observed], sort(setdiff(observed, preferred)))
    tibble(.track = tb$name, .track_no = i, value = ord,
           key = paste(.track, ord, sep = "::"))
  })
  if (!show_unknown) legend_items <- filter(legend_items, value != "Unknown")
  legend_items <- legend_items |> mutate(label = value)
  if (length(legend_labels) > 0) {
    hit <- legend_items$value %in% names(legend_labels)
    legend_items$label[hit] <- unname(legend_labels[legend_items$value[hit]])
  }
  legend_cols <- max(count(legend_items, .track_no)$n) + 1
  legend_table <- map_dfr(seq_len(nrow(category_tracks)), function(i) {
    tb <- category_tracks[i, ]
    items <- filter(legend_items, .track_no == i) |>
      transmute(.track_no, .track, value, key, label)
    hdr <- tibble(.track_no = i, .track = tb$name, value = NA_character_,
                  key = paste0(".h", i), label = tb$name)
    pad <- tibble(.track_no = rep(i, legend_cols - nrow(items) - 1),
                  .track = tb$name, value = NA_character_,
                  key = paste0(".s", i, "_", seq_len(legend_cols - nrow(items) - 1)), label = "")
    bind_rows(hdr, items, pad)
  })
  transparent <- legend_table$key[is.na(legend_table$value)]
  legend_pal <- c(key_pal, rlang::set_names(rep("#00000000", length(transparent)), transparent))

  p <- ggplot() +
    if (show_summary) geom_rect(data = panel_boxes, aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax),
        fill = "white", colour = "grey15", linewidth = .52) else NULL
  p <- p +
    geom_polygon(data = numeric_rings, aes(x, y, group = interaction(.track, .i)),
                 fill = "white", colour = "grey15", linewidth = .30) +
    geom_polygon(data = category_rings, aes(x, y, group = interaction(.track, .i), fill = paste(.track, value, sep = "::")),
                 colour = "grey15", linewidth = .30) +
    scale_fill_manual(values = legend_pal,
                      limits = legend_table$key, breaks = legend_table$key,
                      labels = legend_table$label, name = NULL, drop = FALSE,
                      guide = guide_legend(nrow = nrow(category_tracks), byrow = TRUE,
                                           override.aes = list(colour = NA))) +
    geom_segment(data = lollipops, aes(x0, y0, xend = x1, yend = y1),
                 colour = lollipops$.track |> map_chr(~ numeric_tracks$color[numeric_tracks$name == .x]), linewidth = .38) +
    geom_point(data = lollipops, aes(x1, y1),
               colour = lollipops$.track |> map_chr(~ numeric_tracks$color[numeric_tracks$name == .x]), size = 1.15) +
    geom_text(data = sample_labels, aes(x, y, label = .data[[id_col]], angle = text_angle, hjust = hjust),
              size = 2.5 * font_scale) +
    if (show_summary) geom_boxplot(data = numeric_points, aes(x, y, group = .track),
        width = .53, outlier.shape = NA, fill = "white", colour = "grey35", linewidth = .40) else NULL
  p <- p +
    { if (show_summary) geom_beeswarm(data = numeric_points, aes(x, y, colour = .track, group = .track),
                  cex = 0.7, size = 1.15, alpha = .95, show.legend = FALSE) } +
    scale_colour_manual(values = rlang::set_names(numeric_tracks$color, numeric_tracks$name), guide = "none") +
    { if (show_summary) geom_label(data = quartiles, aes(x, y, label = label), fill = "grey50", colour = "white",
               linewidth = 0, size = 2.45 * font_scale) } +
    { if (show_summary) geom_rect(data = bars, aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax, fill = key), colour = "grey25", linewidth = .40) } +
    { if (show_summary) geom_text(data = bars, aes(x, y, label = sprintf("%.1f", 100 * fraction)),
              colour = "white", fontface = "bold", size = 2.55 * font_scale) } +
    { if (show_summary) geom_segment(data = axis_lines, aes(x, y, xend = xend, yend = yend), linewidth = .34) } +
    { if (show_summary) geom_text(data = axis_labels, aes(x = -.07, y, label = label), hjust = 1, size = 2.7 * font_scale) } +
    { if (show_summary) geom_text(data = panel_titles, aes(x, y, label = label, angle = angle, hjust = hjust),
              lineheight = .88, size = 4 * font_scale) } +
    { if (show_summary) annotate("text", x = -max(numeric_tracks$outer) - .51, y = .62, label = "Participant ID",
             angle = 90, size = 4 * font_scale) } +
    coord_fixed(xlim = c(-outer_radius - .55, outer_radius + .41),
                ylim = c(-outer_radius - .31, outer_radius + .28), ratio = 1, clip = "off") +
    theme_void(base_size = 12 * font_scale) +
    theme(plot.margin = margin(8, 10, 8, 10), legend.position = "top",
          legend.justification = "left",
          legend.text = element_text(size = 11 * font_scale),
          legend.key.size = grid::unit(.45 * font_scale, "cm"))
  p
}

1. 基础环形图

将 show_summary = FALSE 即可只绘制圆环本身:排序后的样本、分类色带、棒棒糖数值轨道、外缘样本标签与分类图例。

p1 <- circular_annotation_plot(dat, id_col = "id", sort_col = "age",
  numeric_tracks = numeric_tracks, category_tracks = category_tracks,
  category_colors = category_colors, show_summary = FALSE)
p1
图 1: 基础环形多轨道样本注释图

2. 缺口处的汇总面板

默认 show_summary = TRUE 时,圆环缺口处会填充队列汇总:每条连续轨道对应一块白色面板(箱线图、蜂群散点、四分位标签),每条分类轨道对应一条高高的 100% 堆叠比例柱,并在中线位置绘制数值轴——即上方示例图的完整布局。

p2 <- circular_annotation_plot(dat, id_col = "id", sort_col = "age",
  numeric_tracks = numeric_tracks, category_tracks = category_tracks,
  category_order = bar_order, legend_order = legend_order,
  category_colors = category_colors, legend_labels = legend_labels,
  tick_values = tick_values, font_scale = 1.25)
p2
图 2: 带缺口汇总面板的环形注释图
提示

关键参数:

  • sort_col:决定样本沿圆环排序的列(升序)。
  • panel_height:缺口内所有汇总面板的统一高度(默认 2.95)。
  • gap_degrees:承载汇总面板的缺口角大小(默认 90°)。
  • tick_values:数值轴刻度值的命名列表,如 list(Age = c(21, 51, 75))。
  • category_order / legend_order:比例柱内部自下而上的堆叠顺序与图例顺序。
  • legend_labels:短代码的展示名映射(如 IS → “Insulin sensitivity”);show_unknown 控制图例是否隐藏 Unknown 类。
  • show_summary / font_scale:绘制缺口面板(TRUE)或仅圆环(FALSE);全局文字缩放。

3. 自定义

轨道表与函数参数让图形易于改换目标:这里把同一队列改按 BMI 排序,并把缺口加宽到 120°。

p3 <- circular_annotation_plot(dat, id_col = "id", sort_col = "bmi",
  numeric_tracks = numeric_tracks, category_tracks = category_tracks,
  category_order = bar_order, legend_order = legend_order,
  category_colors = category_colors, legend_labels = legend_labels,
  tick_values = tick_values, font_scale = 1.25, gap_degrees = 120)
p3
图 3: 自定义示例:按 BMI 排序、缺口 120°

应用场景

  • 队列概览图:多组学与纵向研究开篇图中的参与者特征(人口统计、临床变量、组学分组)——参考论文正是使用这一布局。
  • 临床试验臂:样本按生物标志物或入组顺序排序,轨道编码治疗臂、响应与关键测量值。
  • 单细胞 / 样本级质控:以连续轨道展示每个样本的质量指标,缺口处给出总体分布。
  • 声明式轨道表意味着新增变量只需追加一行;版式引擎(扇区角度、半径映射、面板对齐)保持不变。

参考文献

[1] 顶刊图社 | 第094期:环形多轨道样本注释图. 微信公众号文章. https://mp.weixin.qq.com/s/E2IZKV3eCi9ufdsGOAeE8g

[2] Shen X, Wang C, Zhou X, et al. Nonlinear dynamics of multi-omics profiles during human aging. Nat Aging. 2024;4(11):1619-1634. doi:10.1038/s43587-024-00692-2