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, TimeUnit};
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(),
));
}
}
if spec.encoding.y.time_unit.is_some() {
return Err(timeunit_channel_error("y"));
}
if spec
.encoding
.color
.as_ref()
.is_some_and(|c| c.time_unit.is_some())
{
return Err(timeunit_channel_error("color"));
}
if spec.encoding.x.time_unit.is_some() && spec.mark != Mark::Bar {
return Err(timeunit_mark_error(spec.mark));
}
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));
if x_count && y_count {
return Err(Error::Spec(
"aggregate belongs on exactly one channel".into(),
));
}
if x_count {
if spec.encoding.x.time_unit.is_some() {
return Err(timeunit_xcount_error());
}
return Ok(BarRoute::Horizontal);
}
let resolve = |ch: &Channel, f: &str| -> FieldType {
ch.ty
.or_else(|| table.declared.get(f).copied())
.unwrap_or_else(|| native_type(rows, f))
};
let xt = resolve(&spec.encoding.x, xf);
if spec.encoding.x.time_unit.is_some() {
let xt = resolved_type(&spec.encoding.x, table);
if xt != FieldType::Temporal {
return Err(timeunit_not_temporal_error(spec, table, xf, xt));
}
return Ok(BarRoute::Vertical);
}
if xt == FieldType::Temporal {
return Err(bar_temporal_error());
}
if y_count {
return Ok(BarRoute::Vertical);
}
let x_quant = xt == 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"
))
}
fn bar_temporal_error() -> Error {
Error::Spec(
"bars need discrete time buckets; add `\"timeUnit\": \"day\"` (or week/month/…) \
— or use `line`/`point` for continuous time"
.into(),
)
}
fn temporal_y_error(mark: Mark, field: &str) -> Error {
Error::Data(format!(
"mark {mark:?} resolved y field \"{field}\" as temporal, but y must be quantitative; \
put \"{field}\" on encoding.x (time belongs on x), or aggregate it (e.g. count) for a \
quantitative y"
))
}
fn temporal_color_error(field: &str) -> Error {
Error::Data(format!(
"encoding.color field \"{field}\" resolved as temporal; color would split into one \
series per timestamp — group by a categorical field instead (or bucket time with a \
phase-2 `timeUnit`)"
))
}
fn temporal_parse_error(row: usize, value: &str, field: &str) -> Error {
Error::Data(format!(
"row {row}: could not parse \"{value}\" as temporal in column \"{field}\"; accepted: \
\"2026-07-05\", \"2026-07-05T14:30:00\" (or a space instead of T, optional .fff), either \
with a trailing \"Z\" or \"±hh:mm\" offset, or a bare \"14:30:00\""
))
}
fn type_name(t: FieldType) -> &'static str {
match t {
FieldType::Quantitative => "quantitative",
FieldType::Nominal => "nominal",
FieldType::Ordinal => "ordinal",
FieldType::Temporal => "temporal",
}
}
fn timeunit_not_temporal_error(spec: &Spec, table: &Table, xf: &str, xt: FieldType) -> Error {
let rung = if spec.encoding.x.ty.is_some() {
"the explicit spec `type`"
} else if table.declared.contains_key(xf) {
"the declared column type"
} else {
"inference from the data"
};
Error::Spec(format!(
"`timeUnit` buckets a temporal x, but \"{xf}\" resolved to {} via {rung}; declare the \
column DATE/DATETIME/TIMESTAMP/TIME, or set encoding.x.type to \"temporal\"",
type_name(xt)
))
}
fn unit_name(u: TimeUnit) -> &'static str {
match u {
TimeUnit::Year => "year",
TimeUnit::Quarter => "quarter",
TimeUnit::Month => "month",
TimeUnit::Week => "week",
TimeUnit::Day => "day",
TimeUnit::Hour => "hour",
TimeUnit::Minute => "minute",
}
}
fn timeunit_overflow_error(n: usize, plot_w: usize, unit: TimeUnit) -> Error {
let coarser = match unit {
TimeUnit::Minute => Some("hour or day"),
TimeUnit::Hour => Some("day or week"),
TimeUnit::Day => Some("week or month"),
TimeUnit::Week => Some("month or quarter"),
TimeUnit::Month => Some("quarter or year"),
TimeUnit::Quarter => Some("year"),
TimeUnit::Year => None,
};
let fix = match coarser {
Some(c) => format!("use a coarser timeUnit ({c}) or a wider size"),
None => "use a wider size".to_string(),
};
Error::Data(format!(
"timeUnit \"{}\" spans {n} buckets but the plot is {plot_w} columns; {fix}",
unit_name(unit)
))
}
fn timeunit_xcount_error() -> Error {
Error::Spec(
"`timeUnit` buckets the time (category) axis, but `aggregate: \"count\"` makes \
encoding.x the count value axis; drop `timeUnit`, or move `count` to encoding.y \
and put the time field on encoding.x with `timeUnit`"
.into(),
)
}
fn timeunit_channel_error(channel: &str) -> Error {
Error::Spec(format!(
"`timeUnit` is only supported on encoding.x (it buckets time on the category axis); \
remove it from encoding.{channel}"
))
}
fn timeunit_mark_error(mark: Mark) -> Error {
Error::Spec(format!(
"`timeUnit` buckets bars into discrete periods; mark {mark:?} already plots continuous \
time — drop `timeUnit`, or switch to `bar`"
))
}
fn timeunit_grouped_error(color_field: &str) -> Error {
Error::Spec(format!(
"`timeUnit` bars do not support a grouping `color` (\"{color_field}\") yet; \
drop `color`, or drop `timeUnit`"
))
}
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,
densified: bool,
) -> 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() {
if densified && agg == Aggregate::Count {
vmax = vmax.max(0.0);
out_row.push(Some(0.0));
} else {
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 x_col(xscale: &Linear, v: f64, plot_w: usize) -> usize {
((xscale.norm(v) * (plot_w - 1) as f64).round() as usize).min(plot_w - 1)
}
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| x_col(xscale, *t, plot_w)).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
}