Circular Multi-track Annotation Plot

Authors

[Editor] Shixiang Wang;

[Contributors] Claude Code — AI-generated, independently AI-reviewed;

Modified

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

A circular multi-track sample annotation plot arranges every sample of a cohort as one sector of a ring: each sector carries an outer label, concentric tracks encode different variables (continuous variables as lollipop tracks whose dots’ distance from the centre marks the value, categorical variables as colored bands), and the gap of the ring hosts the cohort summary — a white panel per continuous track with a boxplot, beeswarm points and quartile labels, a 100% stacked proportion bar per categorical track, and compact value axes for the two continuous scales. Every summary panel sits directly across from its own track (same radial position, mirrored to the left) and all panels share one uniform height, so the gap reads as a clean column of statistics; a per-track legend is generated automatically. It is designed for cohort figures that must show many samples and several variables at once (the reference figure shows 108 participants of a longitudinal multi-omics cohort), so individual-level patterns, group composition and overall distributions can be read in a single glance. This tutorial reproduces the chart type covered by the 顶刊图社 series (issue 094, after Shen et al., Nature Aging 2024); see the Reference section below.

Example

CircularAnnotation DEMO

This example shows a simulated 108-participant cohort. Samples are sorted by age around the ring (outer labels); the two outer lollipop tracks show age and BMI (a dot’s distance from the centre = value), and the three inner bands encode insulin response, ethnicity and gender. In the upper-left gap, each continuous track gets a white panel with a boxplot, beeswarm points and grey labels for the three quartiles, while each categorical track gets a tall 100% stacked bar with percentage labels for every segment; the two numeric value axes (75 / 51 / 21 for age, 15 / 30 / 41 for BMI) sit at the ring’s centre line, and the legend at the top dedicates one row to each categorical track.

Setup

  • System Requirements: Cross-platform (Linux/MacOS/Windows)

  • Programming Language: R

  • Dependencies: 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)

Data Preparation

The example simulates a cohort comparable to the reference figure (108 participants) so the tutorial runs without external data. Every row is one participant; age and bmi are the continuous variables, iris, ethnicity and gender the categorical ones.

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

The track layout is described declaratively. Each numeric track defines its column, the radius band it occupies (inner/outer), the value range mapped onto that band (min/max), the track color and optional tick values. Each category track defines its column and radius band; colors (and their order) are supplied through a named vector, and categories without a color receive one automatically.

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

Visualization

The complete figure is produced by one reusable function, circular_annotation_plot(). It converts every sample sector into ordinary cartesian coordinates (so the ring and the summary panels live in a single ggplot2 coordinate system and align exactly), then stacks the category bands, the lollipop tracks, the sample labels, the circular ticks and the gap panels.

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. Basic Ring

Setting show_summary = FALSE draws only the ring itself: ordered samples, category bands, the lollipop value tracks, the outer sample labels and the category legend.

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
Figure 1: Basic circular multi-track sample annotation plot

2. Summary Panels in the Gap

With the default show_summary = TRUE, the gap of the ring is filled with the cohort summary: one white panel per continuous track (boxplot, beeswarm, quartile labels) and one tall 100% stacked proportion bar per categorical track, plus the value axes at the centre line — the full layout shown in the Example figure above.

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
Figure 2: Circular annotation plot with summary panels in the gap
Tip

Key Parameters:

  • sort_col: the column that defines the sample order around the ring (sorted ascending).
  • panel_height: uniform height of every summary panel in the gap (default 2.95).
  • gap_degrees: angular size of the gap (default 90°) that hosts the summary panels.
  • tick_values: named list of axis tick values, e.g. list(Age = c(21, 51, 75)).
  • category_order / legend_order: stacking order (bottom-to-top) inside each proportion bar and the order of legend entries.
  • legend_labels: display names for short category codes (e.g. IS → “Insulin sensitivity”); show_unknown hides the Unknown class from the legend.
  • show_summary / font_scale: draw the gap panels (TRUE) or the bare ring (FALSE); global text scaling.

3. Customization

The track tables and the function parameters make the figure easy to re-target: here the same cohort is re-sorted by BMI and drawn with a wider 120° gap.

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
Figure 3: Customized annotation plot: sorted by BMI with a 120-degree gap

Applications

  • Cohort overview figures: participant characteristics (demographics, clinical variables, omics groups) in the opening figure of multi-omics and longitudinal studies — the reference paper uses exactly this layout.
  • Clinical trial arms: samples sorted by a biomarker or enrollment order, tracks encoding arm, response and key measurements.
  • Single-cell / sample-level QC: per-sample quality metrics as continuous tracks with the panel distributions in the gap.
  • The declarative track tables mean new variables are added by appending one row; the layout engine (sector angles, radius mapping, panel alignment) stays untouched.

Reference

[1] 顶刊图社 | 第094期:环形多轨道样本注释图. WeChat article. 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