use crate::data;
use crate::error::Error;
use crate::ingest::{Row, Table};
use crate::scale::{fmt_tick, Linear};
use crate::scene::{
Bar, BarDirection, Chrome, LegendEntry, Placed, Rect, Scene, SceneMark, SeriesRef, Size,
Source, XAxis, YAxis, YTick,
};
use crate::spec::{Aggregate, Channel, FieldType, Mark, Spec};
use crate::theme::Theme;
use std::collections::HashMap;
mod bars;
mod xy;
use bars::{compile_bar, compile_bar_h};
use xy::compile_xy;
const DEFAULT_WIDTH: usize = 72;
const DEFAULT_HEIGHT: usize = 13;
pub struct CompileOptions {
pub width: Option<usize>,
pub height: Option<usize>,
pub theme: Theme,
}
pub(crate) fn plot_dims(
width: Option<usize>,
height: Option<usize>,
spec: &Spec,
) -> (usize, usize) {
let plot_w = width.or(spec.width).unwrap_or(DEFAULT_WIDTH).max(8);
let plot_h = height.or(spec.height).unwrap_or(DEFAULT_HEIGHT).max(3);
(plot_w, plot_h)
}
fn y_ticks(y: &Linear, plot_h: usize, top: usize) -> Vec<YTick> {
let k = ((y.max - y.min) / y.step).round() as usize;
let spacing = (plot_h - 1) / k;
y.ticks()
.iter()
.enumerate()
.map(|(i, &t)| YTick {
value: t,
frac: y.norm(t),
label: fmt_tick(t, y.step),
row: top + (plot_h - 1) - i * spacing,
})
.collect()
}
pub fn preflight(spec: &Spec, rows: &[Row]) -> Result<(), Error> {
validate(spec)?;
if spec.mark == Mark::Bar {
if !matches!(spec.encoding.x.aggregate, Some(Aggregate::Count)) {
data::check_field(rows, &spec.encoding.x.field)?;
}
if !matches!(spec.encoding.y.aggregate, Some(Aggregate::Count)) {
data::check_field(rows, &spec.encoding.y.field)?;
}
} else {
data::check_field(rows, &spec.encoding.x.field)?;
if !matches!(spec.encoding.y.aggregate, Some(Aggregate::Count)) {
data::check_field(rows, &spec.encoding.y.field)?;
}
}
if let Some(c) = &spec.encoding.color {
data::check_field(rows, &c.field)?;
}
Ok(())
}
fn validate(spec: &Spec) -> Result<(), Error> {
if spec.encoding.x_offset.is_some() {
return Err(Error::Spec(
"`xOffset` is not supported; grouping is expressed with color alone \
— set encoding.color to the grouping field"
.into(),
));
}
if spec.mark != Mark::Bar && spec.encoding.x.aggregate.is_some() {
return Err(Error::Spec(
"`aggregate` on encoding.x is not supported; aggregation runs over y, grouped by x"
.into(),
));
}
if let Some(c) = &spec.encoding.color {
if c.aggregate.is_some() {
return Err(Error::Spec(
"`aggregate` on encoding.color is not supported; put it on encoding.y".into(),
));
}
}
Ok(())
}
enum BarRoute {
Vertical,
Horizontal,
}
fn bar_route(spec: &Spec, table: &Table) -> Result<BarRoute, Error> {
let rows = &table.rows;
let xf = &spec.encoding.x.field;
let yf = &spec.encoding.y.field;
let x_count = matches!(spec.encoding.x.aggregate, Some(Aggregate::Count));
let y_count = matches!(spec.encoding.y.aggregate, Some(Aggregate::Count));
match (x_count, y_count) {
(true, true) => {
return Err(Error::Spec(
"aggregate belongs on exactly one channel".into(),
));
}
(false, true) => return Ok(BarRoute::Vertical),
(true, false) => return Ok(BarRoute::Horizontal),
(false, false) => {}
}
let resolve = |ch: &Channel, f: &str| -> FieldType {
ch.ty
.or_else(|| table.declared.get(f).copied())
.unwrap_or_else(|| native_type(rows, f))
};
let x_quant = resolve(&spec.encoding.x, xf) == FieldType::Quantitative;
let y_quant = resolve(&spec.encoding.y, yf) == FieldType::Quantitative;
match (x_quant, y_quant) {
(false, true) => Ok(BarRoute::Vertical),
(true, false) => Ok(BarRoute::Horizontal),
(true, true) => Err(bar_channel_error(xf, yf, "quantitative")),
(false, false) => {
if spec.encoding.y.ty.is_none() && data::infer_type(rows, yf) == FieldType::Quantitative
{
Ok(BarRoute::Vertical)
} else if spec.encoding.x.ty.is_none()
&& data::infer_type(rows, xf) == FieldType::Quantitative
{
Ok(BarRoute::Horizontal)
} else {
Err(bar_channel_error(xf, yf, "categorical"))
}
}
}
}
fn native_type(rows: &[Row], field: &str) -> FieldType {
let mut saw_value = false;
for row in rows {
if let Some(v) = row.get(field) {
if v.is_null() {
continue;
}
saw_value = true;
if !v.is_number() {
return FieldType::Nominal;
}
}
}
if saw_value {
FieldType::Quantitative
} else {
FieldType::Nominal
}
}
fn bar_channel_error(xf: &str, yf: &str, both: &str) -> Error {
Error::Spec(format!(
"bar needs one categorical and one quantitative channel; both x (\"{xf}\") and y \
(\"{yf}\") resolved {both}; put categories on one axis or set an explicit \"type\""
))
}
fn bar_aggregate_error(value_axis: &str) -> Error {
Error::Spec(format!(
"aggregation runs over the quantitative channel, grouped by the categorical one; \
put `aggregate` on encoding.{value_axis}"
))
}
pub(crate) fn aggregate(values: &[f64], agg: Aggregate) -> f64 {
match agg {
Aggregate::Sum => values.iter().sum(),
Aggregate::Mean => values.iter().sum::<f64>() / values.len() as f64,
Aggregate::Median => {
let mut v = values.to_vec();
v.sort_by(f64::total_cmp);
let mid = v.len() / 2;
if v.len().is_multiple_of(2) {
(v[mid - 1] + v[mid]) / 2.0
} else {
v[mid]
}
}
Aggregate::Min => values.iter().cloned().fold(f64::INFINITY, f64::min),
Aggregate::Max => values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
Aggregate::Count => values.len() as f64,
}
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
fn resolved_type(ch: &Channel, table: &Table) -> FieldType {
ch.ty
.or_else(|| table.declared.get(&ch.field).copied())
.unwrap_or_else(|| data::infer_type(&table.rows, &ch.field))
}
fn tick_gutter(scale: &Linear) -> usize {
scale
.ticks()
.iter()
.map(|t| fmt_tick(*t, scale.step).chars().count())
.max()
.unwrap_or(1)
}
fn place_title(spec: &Spec, gutter: usize, plot_w: usize) -> (Option<Placed>, usize) {
let title = spec.title.as_deref().map(|t| Placed {
text: t.to_string(),
col: gutter + 1 + plot_w.saturating_sub(t.chars().count()) / 2,
row: 0,
});
let rows = if title.is_some() { 2 } else { 0 };
(title, rows)
}
fn name_gutter(cats: &[String]) -> (Vec<String>, usize) {
let names: Vec<String> = cats.iter().map(|c| truncate(c, 24)).collect();
let gutter = names.iter().map(|s| s.chars().count()).max().unwrap_or(1);
(names, gutter)
}
fn negative_bar_error() -> Error {
Error::Data("negative values are not yet supported for mark \"bar\"; use mark \"line\"".into())
}
fn palette_cap_error(n_series: usize, palette_len: usize, color_field: &str) -> Error {
Error::Data(format!(
"{n_series} series exceed the {palette_len} distinguishable series colors; \
aggregate or filter \"{color_field}\""
))
}
fn no_rows_error(value_field: &str, cat_field: &str) -> Error {
Error::Data(format!(
"no usable rows: field \"{value_field}\" has no numeric values \
(or \"{cat_field}\" is always missing)"
))
}
fn height_ceiling_error(n_bars: usize, content: usize) -> Error {
Error::Data(format!(
"{n_bars} bars need height {content}; filter or aggregate, or raise --height"
))
}
struct BarScan {
cats: Vec<String>,
series: Vec<String>,
cells: Vec<Vec<Vec<f64>>>,
dropped: usize,
}
fn scan_bars(
rows: &[Row],
cat_field: &str,
value_field: &str,
series_field: Option<&str>,
agg: Aggregate,
) -> BarScan {
let mut cats: Vec<String> = Vec::new();
let mut series: Vec<String> = match series_field {
Some(_) => Vec::new(),
None => vec![String::new()],
};
let mut raw: HashMap<(usize, usize), Vec<f64>> = HashMap::new();
let mut dropped = 0usize;
for row in rows {
let Some(cv) = row.get(cat_field) else {
dropped += 1;
continue;
};
let vn = if agg == Aggregate::Count {
Some(1.0)
} else {
row.get(value_field).and_then(data::num)
};
let Some(vn) = vn else {
dropped += 1;
continue;
};
let ci = index_of_or_push(&mut cats, data::text(cv));
let si = match series_field {
Some(sf) => {
let name = row.get(sf).map(data::text).unwrap_or_else(|| "null".into());
index_of_or_push(&mut series, name)
}
None => 0,
};
raw.entry((ci, si)).or_default().push(vn);
}
let mut cells = vec![vec![Vec::new(); series.len()]; cats.len()];
for ((ci, si), v) in raw {
cells[ci][si] = v;
}
BarScan {
cats,
series,
cells,
dropped,
}
}
fn index_of_or_push(list: &mut Vec<String>, item: String) -> usize {
match list.iter().position(|s| *s == item) {
Some(i) => i,
None => {
list.push(item);
list.len() - 1
}
}
}
fn sort_cats(cats: Vec<String>, cells: Vec<Vec<Vec<f64>>>) -> (Vec<String>, Vec<Vec<Vec<f64>>>) {
let mut pairs: Vec<(String, Vec<Vec<f64>>)> = cats.into_iter().zip(cells).collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
pairs.into_iter().unzip()
}
#[allow(clippy::type_complexity)]
fn aggregate_cells(
cells: &[Vec<Vec<f64>>],
agg: Aggregate,
) -> Result<(Vec<Vec<Option<f64>>>, f64), Error> {
let mut vmax = f64::NEG_INFINITY;
let mut out = Vec::with_capacity(cells.len());
for row in cells {
let mut out_row = Vec::with_capacity(row.len());
for values in row {
if values.is_empty() {
out_row.push(None);
continue;
}
let v = aggregate(values, agg);
if v < 0.0 {
return Err(negative_bar_error());
}
vmax = vmax.max(v);
out_row.push(Some(v));
}
out.push(out_row);
}
Ok((out, vmax))
}
pub fn compile(spec: &Spec, table: &Table, opts: &CompileOptions) -> Result<Scene, Error> {
preflight(spec, &table.rows)?;
let (plot_w, plot_h) = plot_dims(opts.width, opts.height, spec);
match spec.mark {
Mark::Bar => match bar_route(spec, table)? {
BarRoute::Vertical => compile_bar(spec, table, opts, plot_w, plot_h),
BarRoute::Horizontal => {
compile_bar_h(spec, table, opts, plot_w, opts.height.or(spec.height))
}
},
Mark::Line | Mark::Point | Mark::Area => compile_xy(spec, table, opts, plot_w, plot_h),
}
}
fn value_axis_x(
xscale: &Linear,
plot_w: usize,
gutter: usize,
columns: usize,
label_row: usize,
) -> (Vec<usize>, Vec<Placed>) {
let tks = xscale.ticks();
let tick_cols: Vec<usize> = tks
.iter()
.map(|t| ((xscale.norm(*t) * (plot_w - 1) as f64).round() as usize).min(plot_w - 1))
.collect();
let anchors: Vec<(usize, String)> = tick_cols
.iter()
.zip(&tks)
.map(|(c, t)| (*c, fmt_tick(*t, xscale.step)))
.collect();
let labels = place_x_labels(&anchors, gutter, columns, label_row);
(tick_cols, labels)
}
fn legend_below(
names: &[String],
theme: &Theme,
gutter: usize,
columns: usize,
top: usize,
plot_h: usize,
) -> (Vec<LegendEntry>, usize) {
let legend_row0 = top + plot_h + 2;
let left = gutter + 1;
let max_name = columns.saturating_sub(left + 3);
let (mut col, mut row) = (left, legend_row0);
let mut legend: Vec<LegendEntry> = Vec::new();
for (i, name) in names.iter().enumerate() {
let name = truncate(name, max_name);
let w = 3 + name.chars().count(); if col > left && col + w > columns {
col = left;
row += 1;
}
legend.push(LegendEntry {
name,
color: theme.series(i),
col,
row,
});
col += w + 3;
}
let legend_rows = legend.last().map_or(0, |e| e.row + 1 - legend_row0);
(legend, legend_rows)
}
fn place_x_labels(
anchors: &[(usize, String)],
gutter: usize,
width: usize,
row: usize,
) -> Vec<Placed> {
let mut out = Vec::new();
let mut next_free = 0usize;
for (col, label) in anchors {
let len = label.chars().count();
if len == 0 || len > width {
continue;
}
let start = (gutter + 1 + col).saturating_sub(len / 2).min(width - len);
if start < next_free {
continue;
}
out.push(Placed {
text: label.clone(),
col: start,
row,
});
next_free = start + len + 1;
}
out
}