use clap::Parser;
use ggplot_rs::prelude::*;
mod load;
mod theme;
#[derive(Parser, Debug)]
#[command(name = "ggplot-rs", version, about, long_about = None)]
struct Args {
#[arg(long, value_name = "PATH")]
parquet: Option<String>,
#[arg(long, value_name = "PATH")]
csv: Option<String>,
#[arg(long, value_name = "SQL")]
sql: Option<String>,
#[arg(long, value_name = "PATH")]
db: Option<String>,
#[arg(long)]
describe: bool,
#[arg(long)]
x: Option<String>,
#[arg(long)]
y: Option<String>,
#[arg(long)]
color: Option<String>,
#[arg(long)]
fill: Option<String>,
#[arg(long)]
size: Option<String>,
#[arg(long)]
shape: Option<String>,
#[arg(long)]
group: Option<String>,
#[arg(long)]
label: Option<String>,
#[arg(long, default_value = "point")]
geom: String,
#[arg(long, value_name = "COL")]
facet_wrap: Option<String>,
#[arg(long, value_name = "ROW:COL")]
facet_grid: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
subtitle: Option<String>,
#[arg(long)]
xlab: Option<String>,
#[arg(long)]
ylab: Option<String>,
#[arg(long)]
caption: Option<String>,
#[arg(long)]
log_x: bool,
#[arg(long)]
log_y: bool,
#[arg(long)]
flip: bool,
#[arg(long, default_value = "gray")]
theme: String,
#[arg(long, value_name = "FILE")]
theme_config: Option<String>,
#[arg(long, value_name = "NAME")]
palette: Option<String>,
#[arg(long, value_name = "R,G,B")]
primary: Option<String>,
#[arg(short, long, value_name = "FILE")]
output: Option<String>,
#[arg(long)]
stdout: bool,
#[arg(long, default_value_t = 800)]
width: u32,
#[arg(long, default_value_t = 600)]
height: u32,
}
fn main() {
if let Err(e) = run() {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let args = Args::parse();
let query = load::resolve_query(&args.sql, &args.parquet, &args.csv)
.ok_or("provide one of --sql, --parquet, or --csv")?;
let columns = load::load(&args.db, &query)?;
if args.describe {
load::describe(&columns);
return Ok(());
}
let mut plot = build_plot(&args, columns)?;
plot = apply_output_labels(plot, &args);
if args.stdout {
let svg = plot
.render_svg_with_size(args.width, args.height)
.map_err(|e| format!("render failed: {e:?}"))?;
print!("{svg}");
} else {
let out = args
.output
.as_deref()
.ok_or("provide -o <file> or --stdout")?;
plot.save_with_size(out, args.width, args.height)
.map_err(|e| format!("save failed: {e:?}"))?;
eprintln!("wrote {out}");
}
Ok(())
}
fn build_plot(args: &Args, columns: Vec<(String, Vec<Value>)>) -> Result<GGPlot, String> {
let names: Vec<&str> = columns.iter().map(|(n, _)| n.as_str()).collect();
let check = |col: &Option<String>| -> Result<(), String> {
match col {
Some(c) if !names.contains(&c.as_str()) => Err(format!(
"column '{c}' not found. Available columns: {}",
names.join(", ")
)),
_ => Ok(()),
}
};
for col in [
&args.x,
&args.y,
&args.color,
&args.fill,
&args.size,
&args.shape,
&args.group,
&args.label,
&args.facet_wrap,
] {
check(col)?;
}
let mut aes = Aes::new();
let set = |a: Aes, col: &Option<String>, f: fn(Aes, &str) -> Aes| match col {
Some(c) => f(a, c),
None => a,
};
aes = set(aes, &args.x, |a, c| a.x(c));
aes = set(aes, &args.y, |a, c| a.y(c));
aes = set(aes, &args.color, |a, c| a.color(c));
aes = set(aes, &args.fill, |a, c| a.fill(c));
aes = set(aes, &args.size, |a, c| a.size(c));
aes = set(aes, &args.shape, |a, c| a.shape(c));
aes = set(aes, &args.group, |a, c| a.group(c));
aes = set(aes, &args.label, |a, c| a.label(c));
let mut plot = GGPlot::new(columns).aes(aes);
plot = match args.geom.as_str() {
"point" => plot.geom_point(),
"line" => plot.geom_line(),
"bar" => plot.geom_bar(),
"col" => plot.geom_col(),
"histogram" => plot.geom_histogram(),
"boxplot" => plot.geom_boxplot(),
"violin" => plot.geom_violin(),
"density" => plot.geom_density(),
"area" => plot.geom_area(),
"smooth" => plot.geom_smooth(),
"step" => plot.geom_step(),
"path" => plot.geom_path(),
"tile" => plot.geom_tile(),
"jitter" => plot.geom_jitter(),
"freqpoly" => plot.geom_freqpoly(),
other => return Err(format!("unknown --geom '{other}'")),
};
if let Some(col) = &args.facet_wrap {
plot = plot.facet_wrap(col, None);
} else if let Some(spec) = &args.facet_grid {
let (row, col) = spec.split_once(':').unwrap_or((spec.as_str(), ""));
let row = (!row.is_empty()).then_some(row);
let col = (!col.is_empty()).then_some(col);
plot = plot.facet_grid(row, col);
}
if args.log_x {
plot = plot.scale_x_log10();
}
if args.log_y {
plot = plot.scale_y_log10();
}
if args.flip {
plot = plot.coord_flip();
}
let cfg = match &args.theme_config {
Some(path) => Some(theme::load(path)?),
None => None,
};
let base = theme::preset(&args.theme)?;
let mut th = match &cfg {
Some(c) => c.apply(base)?,
None => base,
};
if let Some(p) = &args.primary {
th.primary = Some(theme::parse_rgb(p)?);
}
plot = plot.theme(th);
let palette = args
.palette
.clone()
.or_else(|| cfg.as_ref().and_then(|c| c.palette.clone()));
if let Some(name) = palette {
let p = theme::parse_palette(&name)?;
plot = plot.scale_color_brewer(p.clone()).scale_fill_brewer(p);
}
Ok(plot)
}
fn apply_output_labels(mut plot: GGPlot, args: &Args) -> GGPlot {
if let Some(t) = &args.title {
plot = plot.title(t);
}
if let Some(t) = &args.subtitle {
plot = plot.subtitle(t);
}
if let Some(t) = &args.xlab {
plot = plot.xlab(t);
}
if let Some(t) = &args.ylab {
plot = plot.ylab(t);
}
if let Some(t) = &args.caption {
plot = plot.caption(t);
}
plot
}