CanvasXpress visualizations for Bioconductor data

Introduction

CanvasXpressBio connects the CanvasXpress interactive visualization library to core Bioconductor data structures. It does not replace the CRAN canvasXpress package — it adds methods that take a Bioconductor object directly and render it as a reproducible CanvasXpress widget, carrying the object’s annotations along for interactive grouping, coloring and sorting.

This vignette builds one SummarizedExperiment and shows it four ways — heatmap, volcano plot, Kaplan-Meier curve and paired boxplot — so the same samples and genes run through every figure.

library(CanvasXpressBio)
library(SummarizedExperiment)

Why CanvasXpressBio?

Bioconductor already has excellent visualization packages. Static heatmaps come from ComplexHeatmap and pheatmap; EnhancedVolcano draws volcano plots; survminer draws Kaplan-Meier curves; and InteractiveComplexHeatmap adds interactivity to a ComplexHeatmap — but through a Shiny application, which needs a running R server to explore the figure.

CanvasXpressBio is complementary rather than a replacement, and is useful when:

  • You want interactivity without a server. Each call returns a self-contained CanvasXpress htmlwidget: hover for values, zoom, reorder and toggle groups all work in a plain knitted vignette, an R Markdown report, a Quarto page or an email — no Shiny runtime. InteractiveComplexHeatmap is the better choice inside a Shiny app; CanvasXpressBio is the better choice for a standalone, shareable HTML artefact.
  • You want one grammar across many figure types. The same SummarizedExperiment drives the heatmap, volcano, survival curve and boxplot below through four small functions, so the colData / rowData annotations are reused consistently instead of being reshaped for a different package per plot.
  • You want the object’s metadata to travel automatically. colData becomes sample overlays and rowData becomes feature overlays, so cohort, time point and gene direction are available for interactive colouring and sorting without extra bookkeeping.

The rest of this vignette demonstrates these four views on a single object.

One dataset for every figure

The study follows 24 patients in two cohorts, each measured at baseline and at week 12, over a panel of twelve marker genes. The colData records the cohort, the time point, the subject, a follow-up time with an event indicator, and whether that subject’s biomarker went up or down.

set.seed(1)

up_genes   <- c("IL6", "CXCL8", "MMP9", "TNF", "CCL2", "VEGFA")
down_genes <- c("ALB", "CDH1", "FOXO1", "PPARG", "ADIPOQ", "LEP")
genes      <- c(up_genes, down_genes)

patients   <- c(sprintf("PA%02d", 1:12), sprintf("PB%02d", 1:12))
cohort     <- rep(c("Cohort A", "Cohort B"), each = 12)
names(cohort) <- patients

# Sample table: each patient measured at Baseline and Week 12.
coldata <- data.frame(
    Patient   = rep(patients, each = 2),
    Condition = rep(c("Baseline", "Week 12"), times = length(patients)),
    stringsAsFactors = FALSE)
coldata$Cohort <- cohort[coldata$Patient]
rownames(coldata) <- paste(coldata$Patient,
    ifelse(coldata$Condition == "Baseline", "BL", "W12"),
    sep = "_")

# Expression: baseline around 8; at week 12 the up genes rise and the down
# genes fall. Cohort A responds consistently; cohort B is heterogeneous — some
# patients respond, a few reverse — so the biomarker moves both ways there.
baseline <- matrix(rnorm(length(genes) * length(patients), 8, 0.8),
    nrow = length(genes), dimnames = list(genes, patients))
effect   <- ifelse(genes %in% up_genes, 1.6, -1.6)
strength <- setNames(rep(1.0, length(patients)), patients)
strength[cohort == "Cohort B"] <-
    runif(sum(cohort == "Cohort B"), -0.7, 0.8)

expr <- matrix(0, nrow = length(genes), ncol = nrow(coldata),
    dimnames = list(genes, rownames(coldata)))
for (j in seq_len(nrow(coldata))) {
    p <- coldata$Patient[j]
    delta <- if (coldata$Condition[j] == "Week 12")
        effect * strength[[p]] + rnorm(length(genes), 0, 0.4) else 0
    expr[, j] <- baseline[, p] + delta
}

# Per-patient survival (cohort A does better) and the direction of the ALB
# change from baseline to week 12 (ALB is the biomarker shown in the boxplot).
alb_bl  <- expr["ALB", coldata$Condition == "Baseline"]
alb_w12 <- expr["ALB", coldata$Condition == "Week 12"]
updown  <- ifelse(alb_w12 >= alb_bl, "up", "down")
surv_time <- round(ifelse(cohort == "Cohort A",
    rexp(length(patients), 1 / 28),
    rexp(length(patients), 1 / 16)) + 2)
surv_status <- rbinom(length(patients), 1,
    ifelse(cohort == "Cohort A", .5, .75))

coldata$Time   <- surv_time[match(coldata$Patient, patients)]
coldata$Status <- surv_status[match(coldata$Patient, patients)]
coldata$updown <- updown[match(coldata$Patient, patients)]

se <- SummarizedExperiment(
    assays  = list(expr = expr),
    colData = S4Vectors::DataFrame(coldata),
    rowData = S4Vectors::DataFrame(
        Direction = ifelse(genes %in% up_genes, "Up", "Down"),
        row.names = genes))
se
#> class: SummarizedExperiment 
#> dim: 12 48 
#> metadata(0):
#> assays(1): expr
#> rownames(12): IL6 CXCL8 ... ADIPOQ LEP
#> rowData names(1): Direction
#> colnames(48): PA01_BL PA01_W12 ... PB12_BL PB12_W12
#> colData names(6): Patient Condition ... Status updown

A heatmap from a SummarizedExperiment

cxplot() turns the assay matrix into an interactive heatmap. colData becomes sample (column) annotations and rowData becomes feature (row) annotations, so the cohort, time point and gene direction travel with the plot. overlays and var_overlays draw chosen annotations as coloured tracks beside the map, and cluster = TRUE clusters the rows and columns with dendrograms. These are the same twelve genes highlighted in the volcano plot below.

cxplot(se, title = "Marker-gene expression",
    overlays = c("Cohort", "Condition"), var_overlays = "Direction",
    cluster = TRUE)

Because SingleCellExperiment and DESeqDataSet both extend SummarizedExperiment, the same method renders them without any extra code.

A volcano plot from differential-expression results

cxvolcano() accepts any result table with a log-fold-change and a p-value column — a DESeq2::results() object, a limma::topTable() data frame, or a plain data frame. Here week 12 is contrasted against baseline; the twelve marker genes from the heatmap are the labelled hits.

# Observed effect for the twelve markers, placed among a genome-wide background.
marker_fc <- rowMeans(expr[, coldata$Condition == "Week 12"]) -
    rowMeans(expr[, coldata$Condition == "Baseline"])
marker_p  <- 10^(-abs(marker_fc) * runif(length(genes), 2.5, 3.5))

n_bg   <- 1988
bg_fc  <- rnorm(n_bg, 0, 0.45)
bg_p   <- pmin(10^(-(abs(bg_fc) * runif(n_bg, 1.0, 3.0))), 1)

res <- data.frame(
    log2FoldChange = c(marker_fc, bg_fc),
    padj           = c(marker_p, bg_p),
    row.names      = c(genes, paste0("bg", seq_len(n_bg))))

cxvolcano(res, title = "Week 12 vs baseline",
    label_genes = genes)

A Kaplan-Meier survival curve

cxsurvival() reads the follow-up time and event status straight from colData. Using the baseline rows gives one record per patient, stratified by cohort.

baseline_se <- se[, se$Condition == "Baseline"]
cxsurvival(baseline_se, time = "Time", status = "Status", group = "Cohort",
    title = "Survival by cohort")

A paired boxplot with per-patient connections

cxboxplot() plots one marker across the two time points, draws each patient’s points, and joins the repeated measures with a line coloured by the direction of change — faceted by cohort.

# Paired t-test per cohort, shown as a label in each panel.
pA <- t.test(alb_w12[cohort == "Cohort A"], alb_bl[cohort == "Cohort A"],
    paired = TRUE)$p.value
pB <- t.test(alb_w12[cohort == "Cohort B"], alb_bl[cohort == "Cohort B"],
    paired = TRUE)$p.value
ptext <- function(p) if (p < 1e-4) "p<0.0001" else sprintf("p=%.3g", p)
ytop <- max(expr["ALB", ]) + 0.3

cxboxplot(se, feature = "ALB", group = "Condition", segregate = "Cohort",
    connect = "Patient", color = "updown",
    title = "ALB over time, per patient",
    colorKey = list(updown = list(
        up = "rgb(224,130,20)",
        down = "rgb(58,102,160)")),
    decorations = list(text = list(
        list(value = ytop, label = ptext(pA), panel = 1,
            smpCenter = TRUE, preTransformed = TRUE),
        list(value = ytop, label = ptext(pB), panel = 2,
            smpCenter = TRUE, preTransformed = TRUE))))

Session info

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] SummarizedExperiment_1.43.0 Biobase_2.73.2             
#>  [3] GenomicRanges_1.65.4        Seqinfo_1.3.2              
#>  [5] IRanges_2.47.5              S4Vectors_0.51.10          
#>  [7] BiocGenerics_0.59.12        generics_0.1.4             
#>  [9] MatrixGenerics_1.25.0       matrixStats_1.5.0          
#> [11] CanvasXpressBio_0.99.4      BiocStyle_2.41.0           
#> 
#> loaded via a namespace (and not attached):
#>  [1] Matrix_1.7-6        jsonlite_2.0.0      compiler_4.6.1     
#>  [4] BiocManager_1.30.27 jquerylib_0.1.4     yaml_2.3.12        
#>  [7] fastmap_1.2.0       lattice_0.23-1      XVector_0.53.0     
#> [10] R6_2.6.1            S4Arrays_1.13.2     knitr_1.52         
#> [13] htmlwidgets_1.6.4   DelayedArray_0.39.8 maketools_1.3.2    
#> [16] bslib_0.12.0        rlang_1.3.0         cachem_1.1.0       
#> [19] xfun_0.61           canvasXpress_1.70.3 sass_0.4.10        
#> [22] sys_3.4.3           otel_0.2.0          SparseArray_1.13.4 
#> [25] cli_3.6.6           digest_0.6.39       grid_4.6.1         
#> [28] lifecycle_1.0.5     evaluate_1.0.5      buildtools_1.0.0   
#> [31] abind_1.4-8         rmarkdown_2.32      tools_4.6.1        
#> [34] htmltools_0.5.9