|
| 1 | +#!/usr/bin/env Rscript |
| 2 | + |
| 3 | +suppressPackageStartupMessages({ |
| 4 | + library(jsonlite) |
| 5 | + library(ggplot2) |
| 6 | +}) |
| 7 | + |
| 8 | +args <- commandArgs(trailingOnly = TRUE) |
| 9 | +if (length(args) != 1L) stop("Expected one JSON configuration path") |
| 10 | +cfg <- jsonlite::fromJSON(args[[1]], simplifyVector = FALSE) |
| 11 | + |
| 12 | +pick <- function(df, explicit = NULL, choices = character()) { |
| 13 | + if (!is.null(explicit) && nzchar(explicit) && explicit %in% names(df)) return(explicit) |
| 14 | + hit <- choices[choices %in% names(df)] |
| 15 | + if (length(hit)) hit[[1]] else NULL |
| 16 | +} |
| 17 | +read_table <- function(file) { |
| 18 | + sep <- if (grepl("\\.csv$", file, ignore.case = TRUE)) "," else "\t" |
| 19 | + read.delim(file, sep = sep, header = TRUE, check.names = FALSE, stringsAsFactors = FALSE, quote = '"', comment.char = "") |
| 20 | +} |
| 21 | +ratio_number <- function(x) { |
| 22 | + x <- as.character(x) |
| 23 | + vapply(x, function(z) { |
| 24 | + if (grepl("/", z, fixed = TRUE)) { q <- strsplit(z, "/", fixed = TRUE)[[1]]; return(as.numeric(q[[1]]) / as.numeric(q[[2]])) } |
| 25 | + as.numeric(z) |
| 26 | + }, numeric(1)) |
| 27 | +} |
| 28 | +theme_pub <- function() theme_classic(base_size = 11, base_family = "sans") + theme(axis.title = element_text(face = "bold"), legend.title = element_text(face = "bold"), plot.title = element_text(face = "bold"), strip.background = element_rect(fill = "#EEF4F1", colour = NA)) |
| 29 | +save_plot <- function(p, png, pdf, width, height, dpi) { |
| 30 | + ggsave(png, p, width = width, height = height, dpi = dpi, bg = "white") |
| 31 | + ggsave(pdf, p, width = width, height = height, device = cairo_pdf, bg = "white") |
| 32 | +} |
| 33 | + |
| 34 | +dir.create(cfg$output_dir, recursive = TRUE, showWarnings = FALSE) |
| 35 | +prefix <- file.path(cfg$output_dir, cfg$output_name) |
| 36 | +png <- paste0(prefix, ".png") |
| 37 | +pdf <- paste0(prefix, ".pdf") |
| 38 | +plot_data_path <- paste0(prefix, ".plot-data.csv") |
| 39 | +df <- read_table(cfg$input_path) |
| 40 | +if (!nrow(df)) stop("Input table has no rows") |
| 41 | +cols <- cfg$columns |
| 42 | +opt <- cfg$options |
| 43 | +alpha <- ifelse(is.null(opt$alpha), 0.05, as.numeric(opt$alpha)) |
| 44 | +lfc <- ifelse(is.null(opt$lfc), 1, as.numeric(opt$lfc)) |
| 45 | +top_n <- ifelse(is.null(opt$top_n), 30L, as.integer(opt$top_n)) |
| 46 | +label_n <- ifelse(is.null(opt$label_n), 10L, as.integer(opt$label_n)) |
| 47 | +width <- ifelse(is.null(opt$width), 7, as.numeric(opt$width)) |
| 48 | +height <- ifelse(is.null(opt$height), 5.5, as.numeric(opt$height)) |
| 49 | +dpi <- ifelse(is.null(opt$dpi), 300L, as.integer(opt$dpi)) |
| 50 | +transform <- ifelse(is.null(opt$transform), "auto", opt$transform) |
| 51 | + |
| 52 | +if (cfg$plot_type == "volcano") { |
| 53 | + xcol <- pick(df, cols$x, c("log2FoldChange", "log2FC", "logFC", "effect")) |
| 54 | + ycol <- pick(df, cols$y, c("padj", "FDR", "adj.P.Val", "pvalue", "PValue")) |
| 55 | + labcol <- pick(df, cols$label, c("gene", "gene_id", "Gene", "ID", names(df)[[1]])) |
| 56 | + if (is.null(xcol) || is.null(ycol)) stop("Volcano plot requires fold-change and adjusted-p columns") |
| 57 | + out <- data.frame(label = as.character(df[[labcol]]), effect = as.numeric(df[[xcol]]), p = pmax(as.numeric(df[[ycol]]), .Machine$double.xmin)) |
| 58 | + out$status <- ifelse(out$p < alpha & out$effect >= lfc, "Up", ifelse(out$p < alpha & out$effect <= -lfc, "Down", "Not significant")) |
| 59 | + out$neglog10p <- -log10(out$p) |
| 60 | + out$show_label <- "" |
| 61 | + idx <- head(order(out$p, -abs(out$effect), na.last = NA), label_n) |
| 62 | + out$show_label[idx] <- out$label[idx] |
| 63 | + p <- ggplot(out, aes(effect, neglog10p, colour = status)) + geom_point(alpha = 0.75, size = 1.8) + |
| 64 | + geom_vline(xintercept = c(-lfc, lfc), linetype = 2, colour = "grey45") + geom_hline(yintercept = -log10(alpha), linetype = 2, colour = "grey45") + |
| 65 | + scale_colour_manual(values = c(Down = "#3B75AF", `Not significant` = "#B8B8B8", Up = "#D8574C")) + labs(x = "log2 fold change", y = "-log10 adjusted P", colour = NULL, title = "Differential expression") + theme_pub() |
| 66 | + if (label_n > 0 && requireNamespace("ggrepel", quietly = TRUE)) p <- p + ggrepel::geom_text_repel(aes(label = show_label), max.overlaps = Inf, size = 3, box.padding = 0.25, show.legend = FALSE) |
| 67 | +} else if (cfg$plot_type %in% c("pca", "heatmap")) { |
| 68 | + gene_col <- pick(df, cols$gene, c("gene", "gene_id", "Gene", "ID", names(df)[[1]])) |
| 69 | + numeric_cols <- names(df)[vapply(df, is.numeric, logical(1))] |
| 70 | + if (length(numeric_cols) < 2) stop("PCA/heatmap requires at least two numeric sample columns") |
| 71 | + mat <- as.matrix(df[numeric_cols]); storage.mode(mat) <- "numeric" |
| 72 | + rownames(mat) <- make.unique(as.character(df[[gene_col]])) |
| 73 | + if (transform == "log2" || (transform == "auto" && all(mat >= 0, na.rm = TRUE) && max(mat, na.rm = TRUE) > 50)) mat <- log2(mat + 1) |
| 74 | + vars <- apply(mat, 1, var, na.rm = TRUE); keep <- head(order(vars, decreasing = TRUE, na.last = NA), min(top_n, nrow(mat))) |
| 75 | + if (cfg$plot_type == "pca") { |
| 76 | + fit <- prcomp(t(mat[keep, , drop = FALSE]), center = TRUE, scale. = TRUE) |
| 77 | + variance <- 100 * fit$sdev^2 / sum(fit$sdev^2) |
| 78 | + out <- data.frame(sample = rownames(fit$x), PC1 = fit$x[, 1], PC2 = fit$x[, 2], group = "Samples", check.names = FALSE) |
| 79 | + if (!is.null(cfg$metadata_path)) { |
| 80 | + meta <- read_table(cfg$metadata_path) |
| 81 | + sample_col <- pick(meta, cols$sample, c("sample", "sample_name", "Sample", names(meta)[[1]])) |
| 82 | + group_col <- pick(meta, cols$group, c("group", "group_name", "Group", "condition")) |
| 83 | + if (!is.null(sample_col) && !is.null(group_col)) out$group <- as.character(meta[[group_col]][match(out$sample, meta[[sample_col]])]) |
| 84 | + } |
| 85 | + p <- ggplot(out, aes(PC1, PC2, colour = group, label = sample)) + geom_hline(yintercept = 0, colour = "grey90") + geom_vline(xintercept = 0, colour = "grey90") + geom_point(size = 3.2) + |
| 86 | + labs(x = sprintf("PC1 (%.1f%%)", variance[[1]]), y = sprintf("PC2 (%.1f%%)", variance[[2]]), colour = NULL, title = "Sample-level PCA") + theme_pub() |
| 87 | + if (requireNamespace("ggrepel", quietly = TRUE)) p <- p + ggrepel::geom_text_repel(size = 3, show.legend = FALSE) |
| 88 | + } else { |
| 89 | + out <- t(scale(t(mat[keep, , drop = FALSE]))) |
| 90 | + out[!is.finite(out)] <- 0 |
| 91 | + png(png, width = width, height = height, units = "in", res = dpi, bg = "white") |
| 92 | + pheatmap::pheatmap(out, border_color = NA, cluster_rows = TRUE, cluster_cols = TRUE, fontsize = 8, main = sprintf("Top %d variable genes (row z-score)", nrow(out)), color = colorRampPalette(c("#315B9A", "#F7F7F7", "#C6413A"))(101)) |
| 93 | + dev.off() |
| 94 | + cairo_pdf(pdf, width = width, height = height) |
| 95 | + pheatmap::pheatmap(out, border_color = NA, cluster_rows = TRUE, cluster_cols = TRUE, fontsize = 8, main = sprintf("Top %d variable genes (row z-score)", nrow(out)), color = colorRampPalette(c("#315B9A", "#F7F7F7", "#C6413A"))(101)) |
| 96 | + dev.off() |
| 97 | + out <- data.frame(gene = rownames(out), out, check.names = FALSE) |
| 98 | + } |
| 99 | +} else if (cfg$plot_type == "expression-boxplot") { |
| 100 | + xcol <- pick(df, cols$x, c("group", "condition", "treatment")); ycol <- pick(df, cols$y, c("value", "expression", "abundance")); facetcol <- pick(df, cols$facet, c("gene", "feature")) |
| 101 | + if (is.null(xcol) || is.null(ycol)) stop("Expression boxplot requires x/group and y/value columns") |
| 102 | + out <- data.frame(group = as.factor(df[[xcol]]), value = as.numeric(df[[ycol]]), feature = if (is.null(facetcol)) "Expression" else as.character(df[[facetcol]])) |
| 103 | + p <- ggplot(out, aes(group, value, fill = group)) + geom_boxplot(width = 0.62, outlier.shape = NA, alpha = 0.7) + geom_jitter(width = 0.12, size = 1.5, alpha = 0.75) + |
| 104 | + scale_fill_brewer(palette = "Set2") + labs(x = NULL, y = "Expression", fill = NULL, title = "Expression distribution") + theme_pub() + theme(legend.position = "none") |
| 105 | + if (length(unique(out$feature)) > 1) p <- p + facet_wrap(~feature, scales = "free_y") |
| 106 | +} else if (cfg$plot_type == "enrichment-dotplot") { |
| 107 | + termcol <- pick(df, cols$term, c("Description", "Term", "term", "pathway")); xcol <- pick(df, cols$x, c("GeneRatio", "gene_ratio", "RichFactor", "ratio")); sizecol <- pick(df, cols$size, c("Count", "count", "GeneCount")); colorcol <- pick(df, cols$color, c("padj", "p.adjust", "FDR", "pvalue")); facetcol <- pick(df, cols$facet, c("Category", "category", "Ontology")) |
| 108 | + if (any(vapply(list(termcol, xcol, sizecol, colorcol), is.null, logical(1)))) stop("Enrichment dotplot requires term, ratio, count, and adjusted-p columns") |
| 109 | + out <- data.frame(term = as.character(df[[termcol]]), ratio = ratio_number(df[[xcol]]), count = as.numeric(df[[sizecol]]), padj = pmax(as.numeric(df[[colorcol]]), .Machine$double.xmin), category = if (is.null(facetcol)) "Enrichment" else as.character(df[[facetcol]])) |
| 110 | + out <- out[order(out$padj, -out$ratio), , drop = FALSE]; out <- head(out, min(top_n, nrow(out))); out$term <- factor(out$term, levels = rev(unique(out$term))) |
| 111 | + p <- ggplot(out, aes(ratio, term, size = count, colour = -log10(padj))) + geom_point(alpha = 0.88) + scale_colour_viridis_c(option = "C", end = 0.92) + |
| 112 | + labs(x = "Gene ratio", y = NULL, size = "Count", colour = "-log10 adj. P", title = "Functional enrichment") + theme_pub() |
| 113 | + if (length(unique(out$category)) > 1) p <- p + facet_grid(category ~ ., scales = "free_y", space = "free_y") |
| 114 | +} else stop("Unsupported plot type") |
| 115 | + |
| 116 | +if (cfg$plot_type != "heatmap") save_plot(p, png, pdf, width, height, dpi) |
| 117 | +write.csv(out, plot_data_path, row.names = FALSE, fileEncoding = "UTF-8") |
| 118 | +manifest <- list(plot_type = cfg$plot_type, png = normalizePath(png, winslash = "/", mustWork = TRUE), pdf = normalizePath(pdf, winslash = "/", mustWork = TRUE), plot_data = normalizePath(plot_data_path, winslash = "/", mustWork = TRUE), rows = nrow(out), generated_at = format(Sys.time(), tz = "UTC", usetz = TRUE)) |
| 119 | +cat("RNA_FIGURE_RESULT=", jsonlite::toJSON(manifest, auto_unbox = TRUE), "\n", sep = "") |
0 commit comments