Created
September 8, 2026 14:23
-
-
Save wader/b56cfb140de7aac31b79a2b338bc396a to your computer and use it in GitHub Desktop.
ggplot CLI tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env Rscript | |
| # ggplot_cli.R — command-line ggplot2 plotting tool | |
| # | |
| # Usage examples: | |
| # cat data.csv | Rscript ggplot_cli.R -x mpg -y hp | |
| # Rscript ggplot_cli.R -i data.csv -o plot.png -x mpg -y hp -t line | |
| # Rscript ggplot_cli.R -i data.csv -x date -y revenue -t bar --yscale log10 --ylabel-fmt dollar | |
| # Rscript ggplot_cli.R -i data.csv -o plot.svg -x mpg -y hp -t line | |
| # cat data.csv | Rscript ggplot_cli.R -x weight -y price --color group -t point --xscale log10 --ylabel-fmt comma -o - | |
| # cat data.csv | Rscript ggplot_cli.R -x mpg -y hp -f svg -o - | |
| suppressPackageStartupMessages({ | |
| library(optparse) | |
| library(ggplot2) | |
| library(scales) | |
| }) | |
| # ── CLI options ────────────────────────────────────────────────────────────── | |
| option_list <- list( | |
| # I/O | |
| make_option(c("-i", "--input"), default = "-", | |
| help = "Input CSV path. Use '-' or omit for stdin [default: stdin]"), | |
| make_option(c("-o", "--output"), default = "plot.png", | |
| help = "Output path. Use '-' for stdout [default: plot.png]"), | |
| make_option(c("-f", "--format"), default = NULL, | |
| help = "Output format: png, svg. Auto-detected from --output extension [default: png]"), | |
| # Aesthetics | |
| make_option(c("-x", "--xcol"), help = "Column name for x-axis (required)"), | |
| make_option(c("-y", "--ycol"), default = NULL, | |
| help = "Column name for y-axis (optional for histogram/density)"), | |
| make_option(c("--color"), default = NULL, | |
| help = "Column name mapped to color/fill aesthetic"), | |
| make_option(c("--facet"), default = NULL, | |
| help = "Column name for facet_wrap (e.g. 'region')"), | |
| # Plot type | |
| make_option(c("-t", "--type"), default = "point", | |
| help = paste("Plot type: point, line, bar, col, histogram,", | |
| "density, box, violin, area, step [default: point]")), | |
| # Axis scales | |
| make_option("--xscale", default = "identity", | |
| help = "X-axis transform: identity, log10, log2, sqrt, reverse [default: identity]"), | |
| make_option("--yscale", default = "identity", | |
| help = "Y-axis transform: identity, log10, log2, sqrt, reverse [default: identity]"), | |
| # Label formatters (scales package) | |
| make_option("--xlabel-fmt", default = NULL, | |
| help = paste("X-axis label format: comma, dollar, percent, scientific,", | |
| "number_si, bytes, date, time, ordinal [default: none]")), | |
| make_option("--ylabel-fmt", default = NULL, | |
| help = paste("Y-axis label format: comma, dollar, percent, scientific,", | |
| "number_si, bytes, date, time, ordinal [default: none]")), | |
| # Labels & appearance | |
| make_option("--title", default = NULL, help = "Plot title"), | |
| make_option("--subtitle", default = NULL, help = "Plot subtitle"), | |
| make_option("--xlab", default = NULL, help = "X-axis label (default: column name)"), | |
| make_option("--ylab", default = NULL, help = "Y-axis label (default: column name)"), | |
| make_option("--theme", default = "minimal", | |
| help = "ggplot2 theme: minimal, bw, classic, light, dark, void, gray [default: minimal]"), | |
| # Dimensions | |
| make_option("--width", default = 1200, type = "integer", help = "Plot width in pixels [default: 1200]"), | |
| make_option("--height", default = 750, type = "integer", help = "Plot height in pixels [default: 750]"), | |
| make_option("--dpi", default = 150, type = "integer", help = "Resolution in DPI [default: 150]"), | |
| # Extras | |
| make_option("--alpha", default = 0.8, type = "double", help = "Point/fill alpha [default: 0.8]"), | |
| make_option("--size", default = 1.5, type = "double", help = "Point/line size [default: 1.5]"), | |
| make_option("--bins", default = 30, type = "integer", help = "Bins for histogram [default: 30]"), | |
| make_option("--position", default = "dodge", | |
| help = "Bar/col position: dodge, stack, fill [default: dodge]"), | |
| make_option("--smooth", default = FALSE, action = "store_true", | |
| help = "Add a smoothing line (geom_smooth) to scatter/line plots"), | |
| make_option("--se", default = FALSE, action = "store_true", | |
| help = "Show confidence interval on smooth line") | |
| ) | |
| opt <- parse_args(OptionParser(option_list = option_list)) | |
| if (is.null(opt$xcol)) { | |
| stop("--xcol / -x is required. Run with --help for usage.", call. = FALSE) | |
| } | |
| # ── Read data ──────────────────────────────────────────────────────────────── | |
| if (opt$input == "-") { | |
| df <- read.csv(file("stdin"), stringsAsFactors = FALSE) | |
| } else { | |
| if (!file.exists(opt$input)) stop("Input file not found: ", opt$input, call. = FALSE) | |
| df <- read.csv(opt$input, stringsAsFactors = FALSE) | |
| } | |
| stopifnot(opt$xcol %in% names(df)) | |
| if (!is.null(opt$ycol)) stopifnot(opt$ycol %in% names(df)) | |
| # ── Helpers ────────────────────────────────────────────────────────────────── | |
| get_theme <- function(name) { | |
| switch(name, | |
| minimal = theme_minimal(), | |
| bw = theme_bw(), | |
| classic = theme_classic(), | |
| light = theme_light(), | |
| dark = theme_dark(), | |
| void = theme_void(), | |
| gray = theme_gray(), | |
| theme_minimal() | |
| ) | |
| } | |
| get_formatter <- function(name) { | |
| if (is.null(name)) return(waiver()) | |
| switch(name, | |
| comma = label_comma(), | |
| dollar = label_dollar(), | |
| percent = label_percent(), | |
| scientific = label_scientific(), | |
| number_si = label_number_si(), | |
| bytes = label_bytes(), | |
| date = label_date(), | |
| time = label_time(), | |
| ordinal = label_ordinal(), | |
| waiver() | |
| ) | |
| } | |
| get_trans <- function(name) { | |
| switch(name, | |
| identity = "identity", | |
| log10 = "log10", | |
| log2 = "log2", | |
| sqrt = "sqrt", | |
| reverse = "reverse", | |
| "identity" | |
| ) | |
| } | |
| # ── Build plot ─────────────────────────────────────────────────────────────── | |
| aes_map <- if (!is.null(opt$ycol)) { | |
| if (!is.null(opt$color)) { | |
| aes(x = .data[[opt$xcol]], y = .data[[opt$ycol]], color = .data[[opt$color]], | |
| fill = .data[[opt$color]]) | |
| } else { | |
| aes(x = .data[[opt$xcol]], y = .data[[opt$ycol]]) | |
| } | |
| } else { | |
| if (!is.null(opt$color)) { | |
| aes(x = .data[[opt$xcol]], color = .data[[opt$color]], fill = .data[[opt$color]]) | |
| } else { | |
| aes(x = .data[[opt$xcol]]) | |
| } | |
| } | |
| p <- ggplot(df, aes_map) | |
| geom_layer <- switch(opt$type, | |
| point = geom_point(alpha = opt$alpha, size = opt$size), | |
| line = geom_line(alpha = opt$alpha, linewidth = opt$size), | |
| step = geom_step(alpha = opt$alpha, linewidth = opt$size), | |
| bar = geom_bar(alpha = opt$alpha, stat = "count", position = opt$position), | |
| col = geom_col(alpha = opt$alpha, position = opt$position), | |
| histogram = geom_histogram(alpha = opt$alpha, bins = opt$bins, position = opt$position), | |
| density = geom_density(alpha = opt$alpha), | |
| box = geom_boxplot(alpha = opt$alpha), | |
| violin = geom_violin(alpha = opt$alpha), | |
| area = geom_area(alpha = opt$alpha), | |
| stop("Unknown plot type: ", opt$type, call. = FALSE) | |
| ) | |
| p <- p + geom_layer | |
| # Optional smooth line | |
| if (opt$smooth && opt$type %in% c("point", "line", "step")) { | |
| p <- p + geom_smooth(se = opt$se) | |
| } | |
| # ── Scales ─────────────────────────────────────────────────────────────────── | |
| if (is.numeric(df[[opt$xcol]])) { | |
| p <- p + scale_x_continuous( | |
| trans = get_trans(opt$xscale), | |
| labels = get_formatter(opt$`xlabel-fmt`) | |
| ) | |
| } | |
| if (!is.null(opt$ycol) && is.numeric(df[[opt$ycol]])) { | |
| p <- p + scale_y_continuous( | |
| trans = get_trans(opt$yscale), | |
| labels = get_formatter(opt$`ylabel-fmt`) | |
| ) | |
| } | |
| # ── Labels, theme, facets ──────────────────────────────────────────────────── | |
| p <- p + labs( | |
| title = opt$title, | |
| subtitle = opt$subtitle, | |
| x = if (!is.null(opt$xlab)) opt$xlab else opt$xcol, | |
| y = if (!is.null(opt$ylab)) opt$ylab else opt$ycol | |
| ) | |
| p <- p + get_theme(opt$theme) | |
| if (!is.null(opt$facet)) { | |
| p <- p + facet_wrap(as.formula(paste("~", opt$facet))) | |
| } | |
| # ── Resolve output format ──────────────────────────────────────────────────── | |
| out_fmt <- if (!is.null(opt$format)) { | |
| match.arg(opt$format, c("png", "svg")) | |
| } else if (opt$output != "-") { | |
| ext <- tolower(tools::file_ext(opt$output)) | |
| if (ext %in% c("png", "svg")) ext else "png" | |
| } else { | |
| "png" | |
| } | |
| # ── Save / write ───────────────────────────────────────────────────────────── | |
| width_in <- opt$width / opt$dpi | |
| height_in <- opt$height / opt$dpi | |
| save_args <- list(plot = p, width = width_in, height = height_in, bg = "white") | |
| if (out_fmt == "png") save_args$dpi <- opt$dpi | |
| svg_device <- if (requireNamespace("svglite", quietly = TRUE)) svglite::svglite else grDevices::svg | |
| if (opt$output == "-") { | |
| tmp <- tempfile(fileext = paste0(".", out_fmt)) | |
| if (out_fmt == "svg") save_args$device <- svg_device | |
| do.call(ggsave, c(list(filename = tmp), save_args)) | |
| raw_bytes <- readBin(tmp, "raw", file.info(tmp)$size) | |
| con <- pipe("cat", "wb") | |
| writeBin(raw_bytes, con) | |
| close(con) | |
| unlink(tmp) | |
| } else { | |
| if (out_fmt == "svg") save_args$device <- svg_device | |
| do.call(ggsave, c(list(filename = opt$output), save_args)) | |
| msg <- paste0("Saved: ", opt$output, " (", opt$width, "x", opt$height, " px") | |
| if (out_fmt == "png") msg <- paste0(msg, " @ ", opt$dpi, " dpi") | |
| message(msg, ")") | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment