use super::palette;
use super::project::Dir;
use super::scale::{self, Scale};
use crate::error::Error;
use crate::expr::{self, Expr, FuncTable, Value as ExprValue};
use crate::resolve::{AttrMap, MarkerKind, NodeKind, ResolvedInst, ResolvedValue};
use crate::span::Span;
pub enum Side {
Bottom,
Top,
Left,
Right,
}
pub enum Grid {
Default,
Off,
Color(ResolvedValue),
}
pub enum SeriesKind {
Bars,
Line,
Dots,
Area,
}
pub enum Data {
Categorical(Vec<f64>),
Points(Vec<(f64, f64)>),
Formula(Vec<Expr>),
}
pub enum Curve {
Linear,
Smooth,
Step,
}
pub enum BarMode {
Grouped,
Stacked,
Overlay,
}
pub enum AxisRef {
X,
Value(usize),
}
pub enum MarkAt {
Line(f64),
Point(f64, f64),
}
pub struct Band {
pub axis: AxisRef,
pub span: (f64, f64),
pub label: Option<String>,
pub fill: Option<ResolvedValue>,
pub tick: ResolvedValue,
}
pub struct Mark {
pub axis: AxisRef,
pub at: MarkAt,
pub label: Option<String>,
pub dot: bool,
pub color: ResolvedValue,
pub stroke_style: Option<ResolvedValue>,
}
pub struct Bubble {
pub at: (f64, f64),
pub value: f64,
pub axis: usize,
pub label: Option<String>,
pub color: ResolvedValue,
pub outline: Option<(ResolvedValue, f64)>,
}
pub struct Series {
pub kind: SeriesKind,
pub data: Data,
pub label: Option<String>,
pub color: ResolvedValue,
pub axis: usize,
pub marker: bool,
pub curve: Curve,
pub stroke_style: Option<ResolvedValue>,
pub outline: Option<(ResolvedValue, f64)>,
pub thickness: f64,
pub radius: f64,
pub dot: (f64, f64),
pub baseline: Option<f64>,
}
pub struct ValueAxis {
pub side: Side,
pub scale: Scale,
pub title: Option<String>,
pub unit: Option<String>,
pub grid: Grid,
pub primary: bool,
}
pub struct XAxis {
pub scale: Scale,
pub labels: Vec<String>,
pub title: Option<String>,
pub unit: Option<String>,
pub grid: Grid,
}
pub struct Chart {
pub title: Option<String>,
pub x: XAxis,
pub values: Vec<ValueAxis>,
pub series: Vec<Series>,
pub bands: Vec<Band>,
pub marks: Vec<Mark>,
pub bubbles: Vec<Bubble>,
pub bars: BarMode,
pub dir: Dir,
pub gap: f64,
}
pub struct Slice {
pub value: f64,
pub label: Option<String>,
pub color: ResolvedValue,
pub outline: Option<(ResolvedValue, f64)>,
}
pub struct Pie {
pub slices: Vec<Slice>,
pub title: Option<String>,
pub hole: f64,
pub gap: f64,
}
enum End {
Num(f64),
Auto,
}
type Split<'a> = (
Vec<&'a ResolvedInst>,
Vec<&'a ResolvedInst>,
Vec<&'a ResolvedInst>,
Vec<&'a ResolvedInst>,
Vec<&'a ResolvedInst>,
Option<String>,
);
struct AxisSpec<'a> {
id: Option<&'a str>,
side: Side,
title: Option<String>,
unit: Option<String>,
grid: Grid,
range: Option<(End, End)>,
step: Option<f64>,
ticks: Option<Vec<f64>>,
log: bool,
}
pub fn build(inst: &ResolvedInst, funcs: &FuncTable) -> Result<Chart, Error> {
let span = inst.span;
let dir = read_direction(&inst.attrs)?;
let samples = sample_count(&inst.attrs);
let bars = read_bars(&inst.attrs)?;
let (series_insts, axis_insts, band_insts, mark_insts, bubble_insts, title) = partition(inst)?;
if series_insts.is_empty() && bubble_insts.is_empty() {
return Err(Error::at(span, "a chart needs at least one series"));
}
let categories = read_categories(&inst.attrs, span)?;
let mut x_inst: Option<&ResolvedInst> = None;
let mut value_specs: Vec<AxisSpec> = Vec::new();
let default_value_side = if dir == Dir::Row {
Side::Bottom
} else {
Side::Left
};
for ax in &axis_insts {
let side = read_side(ax)?;
match dir {
Dir::Radial => {
if side.is_some() {
return Err(Error::at(
ax.span,
"'side' has no meaning in a radial chart — it has one radius axis",
));
}
value_specs.push(axis_spec(ax, Side::Left)?);
}
Dir::Row => match side {
Some(Side::Left | Side::Right) => x_inst = Some(ax),
_ => value_specs.push(axis_spec(ax, side.unwrap_or(Side::Bottom))?),
},
Dir::Column => match side {
Some(Side::Bottom | Side::Top) => x_inst = Some(ax),
_ => value_specs.push(axis_spec(ax, side.unwrap_or(Side::Left))?),
},
}
}
if categories.is_some() && x_inst.is_some_and(|a| a.attrs.get("labels").is_some()) {
return Err(Error::at(
span,
"set 'categories' or an axis 'labels', not both",
));
}
if value_specs.is_empty() {
value_specs.push(AxisSpec {
id: None,
side: default_value_side,
title: None,
unit: None,
grid: Grid::Default,
range: None,
step: None,
ticks: None,
log: false,
});
}
let mut series = Vec::with_capacity(series_insts.len());
for (i, si) in series_insts.iter().enumerate() {
series.push(read_series(si, i, &value_specs, &categories, span)?);
}
let x_id = x_inst.and_then(|a| a.id.as_deref());
let bands: Vec<Band> = band_insts
.iter()
.map(|b| read_band(b, x_id, &value_specs))
.collect::<Result<_, _>>()?;
let marks: Vec<Mark> = mark_insts
.iter()
.map(|m| read_mark(m, x_id, &value_specs))
.collect::<Result<_, _>>()?;
let bubbles: Vec<Bubble> = bubble_insts
.iter()
.enumerate()
.map(|(i, b)| read_bubble(b, i, &value_specs))
.collect::<Result<_, _>>()?;
let segments: Vec<(f64, f64)> = bands
.iter()
.filter(|b| matches!(b.axis, AxisRef::X))
.map(|b| b.span)
.collect();
let x = build_x_axis(x_inst, &categories, &series, &segments, &bubbles, span)?;
for (si, s) in series_insts.iter().zip(series.iter_mut()) {
if let Data::Formula(exprs) = &s.data {
s.data = sample_formula(exprs, &x.scale, samples, funcs, si.span, &segments)?;
}
}
if let Some(cats) = &categories {
for (si, s) in series_insts.iter().zip(&series) {
if let Data::Categorical(v) = &s.data
&& v.len() != cats.len()
{
return Err(Error::at(
si.span,
format!(
"series data has {} values but the chart has {} categories",
v.len(),
cats.len()
),
));
}
}
}
let values = build_value_axes(value_specs, &series, &bars, &bubbles)?;
Ok(Chart {
title,
x,
values,
series,
bands,
marks,
bubbles,
bars,
dir,
gap: read_gap(&inst.attrs),
})
}
fn read_gap(attrs: &AttrMap) -> f64 {
attrs.number("gap").unwrap_or(10.0)
}
fn read_bubble(inst: &ResolvedInst, index: usize, specs: &[AxisSpec]) -> Result<Bubble, Error> {
let needs = || Error::at(inst.span, "a '|bubble|' needs 'at:' (x y) and 'value:'");
let MarkAt::Point(x, y) = read_at(inst).map_err(|_| needs())? else {
return Err(needs());
};
let value = inst.attrs.number("value").ok_or_else(needs)?;
let color = fill_color(&inst.attrs).unwrap_or_else(|| live(palette::hue(index)));
Ok(Bubble {
at: (x, y),
value,
axis: bind_axis(inst, specs)?,
label: label_of(inst),
color,
outline: outline(&inst.attrs),
})
}
pub fn build_pie(inst: &ResolvedInst) -> Result<Pie, Error> {
let span = inst.span;
let hole = read_hole(&inst.attrs)?;
let mut title = None;
let mut slice_insts = Vec::new();
for child in &inst.children {
if child.kind == NodeKind::Text {
if title.is_none() {
title = child
.label
.as_deref()
.filter(|t| !t.is_empty())
.map(str::to_string);
}
continue;
}
match tag(child) {
Some("slice") => slice_insts.push(child),
_ => return Err(Error::at(child.span, "a pie's children are '|slice|' only")),
}
}
if slice_insts.is_empty() {
return Err(Error::at(span, "a pie needs at least one '|slice|'"));
}
let mut slices = Vec::with_capacity(slice_insts.len());
for (i, s) in slice_insts.iter().enumerate() {
let value = s
.attrs
.number("value")
.ok_or_else(|| Error::at(s.span, "a '|slice|' needs a 'value:'"))?;
if value < 0.0 {
return Err(Error::at(s.span, "a '|slice|' value must be ≥ 0"));
}
let color = fill_color(&s.attrs).unwrap_or_else(|| live(palette::hue(i)));
slices.push(Slice {
value,
label: label_of(s),
color,
outline: outline(&s.attrs),
});
}
if slices.iter().map(|s| s.value).sum::<f64>() <= 0.0 {
return Err(Error::at(span, "a pie's slice values sum to zero"));
}
Ok(Pie {
slices,
title,
hole,
gap: read_gap(&inst.attrs),
})
}
fn read_hole(attrs: &AttrMap) -> Result<f64, Error> {
match attrs.get("hole") {
None => Ok(0.0),
Some(ResolvedValue::Number(n)) if *n >= 0.0 && *n < 1.0 => Ok(*n),
_ => Err(Error::at(Span::empty(), "'hole' is a fraction 0..1")),
}
}
fn read_direction(attrs: &AttrMap) -> Result<Dir, Error> {
match attrs.get("direction") {
None => Ok(Dir::Column),
Some(ResolvedValue::Ident(s)) => match s.as_str() {
"column" => Ok(Dir::Column),
"row" => Ok(Dir::Row),
"radial" => Ok(Dir::Radial),
_ => Err(Error::at(
Span::empty(),
"'direction' is column, row, or radial",
)),
},
_ => Err(Error::at(
Span::empty(),
"'direction' is column, row, or radial",
)),
}
}
fn partition(inst: &ResolvedInst) -> Result<Split<'_>, Error> {
let mut series = Vec::new();
let mut axes = Vec::new();
let mut bands = Vec::new();
let mut marks = Vec::new();
let mut bubbles = Vec::new();
let mut title = None;
for child in &inst.children {
if child.kind == NodeKind::Text {
if title.is_none() {
title = child
.label
.as_deref()
.filter(|t| !t.is_empty())
.map(str::to_string);
}
continue;
}
match tag(child) {
Some("bars") | Some("dots") | Some("line") | Some("area") => series.push(child),
Some("axis") => axes.push(child),
Some("band") => bands.push(child),
Some("mark") => marks.push(child),
Some("bubble") => bubbles.push(child),
Some("slice") => {
return Err(Error::at(
child.span,
"'|slice|' belongs in a 'layout: pie'",
));
}
Some(other) => {
return Err(Error::at(
child.span,
format!("'|{other}|' arrives in a later charts step"),
));
}
None => {
return Err(Error::at(
child.span,
"a chart's children are series (e.g. '|bars|', '|line|') and '|axis|'",
));
}
}
}
Ok((series, axes, bands, marks, bubbles, title))
}
fn tag(inst: &ResolvedInst) -> Option<&str> {
const TAGS: &[&str] = &[
"line", "area", "bars", "dots", "bubble", "slice", "axis", "band", "mark",
];
if inst.kind == NodeKind::Line {
return Some("line");
}
inst.type_chain
.iter()
.rev()
.find_map(|t| TAGS.iter().copied().find(|&tag| tag == t))
}
fn read_series(
inst: &ResolvedInst,
index: usize,
value_specs: &[AxisSpec],
categories: &Option<Vec<String>>,
_chart_span: Span,
) -> Result<Series, Error> {
let kind = match tag(inst) {
Some("bars") => SeriesKind::Bars,
Some("dots") => SeriesKind::Dots,
Some("area") => SeriesKind::Area,
_ => SeriesKind::Line,
};
let has_data = inst.attrs.get("data").is_some();
let has_fn = matches!(inst.attrs.get("fn"), Some(ResolvedValue::Deferred(_)));
let data = match (has_data, has_fn) {
(true, true) => {
return Err(Error::at(
inst.span,
"a series takes 'data' or 'fn', not both",
));
}
(false, false) => return Err(Error::at(inst.span, "a series needs 'data' or 'fn'")),
(false, true) => match inst.attrs.get("fn") {
Some(ResolvedValue::Deferred(exprs)) => Data::Formula(exprs.clone()),
_ => return Err(Error::at(inst.span, "a series needs 'data' or 'fn'")),
},
(true, false) => read_data(inst, &kind)?,
};
if categories.is_some() && !matches!(data, Data::Categorical(_)) {
return Err(Error::at(
inst.span,
"point / formula data needs a numeric x axis, not 'categories'",
));
}
let axis = bind_axis(inst, value_specs)?;
let fill = fill_color(&inst.attrs);
let stroke = real_color(inst.attrs.get("stroke"));
let color = match kind {
SeriesKind::Bars | SeriesKind::Area => fill,
SeriesKind::Line => stroke.or(fill),
SeriesKind::Dots => fill.or(stroke),
}
.unwrap_or_else(|| {
let suffix = match kind {
SeriesKind::Line => "-deep",
SeriesKind::Dots => "-ink",
SeriesKind::Bars | SeriesKind::Area => "",
};
live(&format!("{}{}", palette::hue(index), suffix))
});
let dot_w = inst.attrs.number("width").unwrap_or(7.0);
let dot_h = inst.attrs.number("height").unwrap_or(dot_w);
Ok(Series {
kind,
data,
label: label_of(inst),
color,
axis,
marker: has_marker(inst),
curve: read_curve(&inst.attrs)?,
stroke_style: inst.attrs.get("stroke-style").cloned(),
outline: outline(&inst.attrs),
thickness: inst.attrs.number("stroke-width").unwrap_or(2.0),
radius: inst.attrs.number("radius").unwrap_or(0.0),
dot: (dot_w, dot_h),
baseline: inst.attrs.number("baseline"),
})
}
fn sample_count(attrs: &AttrMap) -> usize {
attrs
.number("samples")
.filter(|n| *n >= 2.0)
.map(|n| n as usize)
.unwrap_or(24)
}
fn sample_formula(
exprs: &[Expr],
x: &Scale,
samples: usize,
funcs: &FuncTable,
span: Span,
segments: &[(f64, f64)],
) -> Result<Data, Error> {
let n = samples.max(2);
if exprs.len() == 1 {
let (min, max) = match x {
Scale::Linear { min, max, .. } | Scale::Log { min, max, .. } => (*min, *max),
Scale::Band { .. } => {
return Err(Error::at(span, "a 'fn:' series needs a numeric x axis"));
}
};
let xs: Vec<f64> = (0..n)
.map(|i| min + (max - min) * i as f64 / (n - 1) as f64)
.collect();
let ys = expr::sample(&exprs[0], "x", &xs, funcs).map_err(|e| Error::at(span, e.0))?;
return Ok(Data::Points(points_from(&xs, ys, span)?));
}
if exprs.len() != segments.len() {
return Err(Error::at(
span,
format!(
"'fn' has {} formulas but the chart has {} bands",
exprs.len(),
segments.len()
),
));
}
let us: Vec<f64> = (0..n).map(|i| i as f64 / (n - 1) as f64).collect();
let mut pts = Vec::new();
for (expr, &(a, b)) in exprs.iter().zip(segments) {
let ys = expr::sample(expr, "u", &us, funcs).map_err(|e| Error::at(span, e.0))?;
let xs: Vec<f64> = us.iter().map(|u| a + (b - a) * u).collect();
pts.extend(points_from(&xs, ys, span)?);
}
Ok(Data::Points(pts))
}
fn points_from(xs: &[f64], ys: Vec<ExprValue>, span: Span) -> Result<Vec<(f64, f64)>, Error> {
let mut pts = Vec::with_capacity(xs.len());
for (&xv, yv) in xs.iter().zip(ys) {
match yv {
ExprValue::Number(y) => pts.push((xv, y)),
ExprValue::Point(..) => {
return Err(Error::at(span, "a 'fn:' expression must return a number"));
}
}
}
Ok(pts)
}
fn read_data(inst: &ResolvedInst, kind: &SeriesKind) -> Result<Data, Error> {
match inst.attrs.get("data") {
Some(ResolvedValue::Number(n)) => Ok(Data::Categorical(vec![*n])),
Some(ResolvedValue::Tuple(items)) => Ok(Data::Categorical(numbers(items, inst.span)?)),
Some(ResolvedValue::List(items)) => {
if matches!(kind, SeriesKind::Bars) {
return Err(Error::at(
inst.span,
"'|bars|' takes categorical data ('data: 9 15 24'), not 'x y' points",
));
}
let mut pts = Vec::with_capacity(items.len());
for it in items {
match it {
ResolvedValue::Tuple(pair) if pair.len() == 2 => {
pts.push((number(&pair[0], inst.span)?, number(&pair[1], inst.span)?));
}
_ => return Err(Error::at(inst.span, "point data is 'x y' pairs")),
}
}
Ok(Data::Points(pts))
}
_ => Err(Error::at(inst.span, "'data' must be a list of numbers")),
}
}
fn bind_axis(inst: &ResolvedInst, specs: &[AxisSpec]) -> Result<usize, Error> {
let Some(id) = axis_id(inst) else {
return Ok(0);
};
if let Some(pos) = specs.iter().position(|a| a.id == Some(id)) {
return Ok(pos);
}
let known: Vec<&str> = specs.iter().filter_map(|a| a.id).collect();
Err(no_axis(id, &known, inst.span))
}
fn axis_id(inst: &ResolvedInst) -> Option<&str> {
match inst.attrs.get("axis") {
Some(ResolvedValue::Ident(s)) => Some(s.as_str()),
_ => None,
}
}
fn no_axis(id: &str, known: &[&str], span: Span) -> Error {
let listed: Vec<String> = known.iter().map(|s| format!("'{s}'")).collect();
let hint = if listed.is_empty() {
String::new()
} else {
format!("; did you mean {}?", listed.join(", "))
};
Error::at(span, format!("axis '{id}' not found{hint}"))
}
fn lookup_axis(
id: &str,
x_id: Option<&str>,
specs: &[AxisSpec],
span: Span,
) -> Result<AxisRef, Error> {
if x_id == Some(id) {
return Ok(AxisRef::X);
}
if let Some(pos) = specs.iter().position(|a| a.id == Some(id)) {
return Ok(AxisRef::Value(pos));
}
let mut known: Vec<&str> = Vec::new();
known.extend(x_id);
known.extend(specs.iter().filter_map(|a| a.id));
Err(no_axis(id, &known, span))
}
fn read_band(inst: &ResolvedInst, x_id: Option<&str>, specs: &[AxisSpec]) -> Result<Band, Error> {
let axis = match axis_id(inst) {
Some(id) => lookup_axis(id, x_id, specs, inst.span)?,
None => AxisRef::X,
};
let fill = real_color(inst.attrs.get("fill"));
let tick = fill.clone().unwrap_or_else(muted);
Ok(Band {
axis,
span: read_span(inst)?,
label: label_of(inst),
fill,
tick,
})
}
fn read_mark(inst: &ResolvedInst, x_id: Option<&str>, specs: &[AxisSpec]) -> Result<Mark, Error> {
let axis = match axis_id(inst) {
Some(id) => lookup_axis(id, x_id, specs, inst.span)?,
None => return Err(Error::at(inst.span, "a '|mark|' needs 'axis:' to place it")),
};
let color = real_color(inst.attrs.get("stroke"))
.or_else(|| real_color(inst.attrs.get("fill")))
.unwrap_or_else(muted);
Ok(Mark {
axis,
at: read_at(inst)?,
label: label_of(inst),
dot: has_marker(inst),
color,
stroke_style: inst.attrs.get("stroke-style").cloned(),
})
}
fn read_span(inst: &ResolvedInst) -> Result<(f64, f64), Error> {
match inst.attrs.get("span") {
Some(ResolvedValue::Tuple(items)) if items.len() == 2 => {
Ok((number(&items[0], inst.span)?, number(&items[1], inst.span)?))
}
_ => Err(Error::at(inst.span, "a '|band|' needs 'span: a b'")),
}
}
fn read_at(inst: &ResolvedInst) -> Result<MarkAt, Error> {
match inst.attrs.get("at") {
Some(ResolvedValue::Number(v)) => Ok(MarkAt::Line(*v)),
Some(ResolvedValue::Tuple(items)) if items.len() == 2 => Ok(MarkAt::Point(
number(&items[0], inst.span)?,
number(&items[1], inst.span)?,
)),
_ => Err(Error::at(
inst.span,
"'at' takes one value (a line) or two (a point)",
)),
}
}
fn read_bars(attrs: &AttrMap) -> Result<BarMode, Error> {
match attrs.get("bars") {
None => Ok(BarMode::Grouped),
Some(ResolvedValue::Ident(s)) => match s.as_str() {
"grouped" => Ok(BarMode::Grouped),
"stacked" => Ok(BarMode::Stacked),
"overlay" => Ok(BarMode::Overlay),
_ => Err(Error::at(
Span::empty(),
"'bars' is grouped, stacked, or overlay",
)),
},
_ => Err(Error::at(
Span::empty(),
"'bars' is grouped, stacked, or overlay",
)),
}
}
fn build_x_axis(
x_inst: Option<&ResolvedInst>,
categories: &Option<Vec<String>>,
series: &[Series],
segments: &[(f64, f64)],
bubbles: &[Bubble],
span: Span,
) -> Result<XAxis, Error> {
let (title, unit, grid) = match x_inst {
Some(a) => (label_of(a), read_unit(a)?, read_grid(a)?),
None => (None, None, Grid::Default),
};
let any_numeric = !bubbles.is_empty()
|| series
.iter()
.any(|s| matches!(s.data, Data::Points(_) | Data::Formula(_)));
if let Some(cats) = categories {
return Ok(XAxis {
scale: Scale::band(cats.len()),
labels: cats.clone(),
title,
unit,
grid,
});
}
if !any_numeric {
let n = series
.iter()
.map(|s| match &s.data {
Data::Categorical(v) => v.len(),
_ => 0,
})
.max()
.unwrap_or(0);
if n == 0 {
return Err(Error::at(
span,
"a chart series needs at least one data value",
));
}
return Ok(XAxis {
scale: Scale::band(n),
labels: Vec::new(),
title,
unit,
grid,
});
}
let mut xs: Vec<f64> = series
.iter()
.flat_map(|s| match &s.data {
Data::Points(p) => p.iter().map(|(x, _)| *x).collect::<Vec<_>>(),
_ => Vec::new(),
})
.collect();
xs.extend(bubbles.iter().map(|b| b.at.0));
if xs.is_empty() {
for &(a, b) in segments {
xs.push(a);
xs.push(b);
}
}
let range = x_inst.map(read_range).transpose()?.flatten();
if range.is_none() && !bubbles.is_empty() {
let lo = xs.iter().copied().fold(f64::INFINITY, f64::min);
let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let pad = ((hi - lo) * 0.1).max(1.0);
xs.push(lo - pad);
xs.push(hi + pad);
}
let scale = numeric_scale(&xs, range, x_inst)?;
Ok(XAxis {
scale,
labels: Vec::new(),
title,
unit,
grid,
})
}
fn build_value_axes(
specs: Vec<AxisSpec>,
series: &[Series],
bars: &BarMode,
bubbles: &[Bubble],
) -> Result<Vec<ValueAxis>, Error> {
let mut out = Vec::with_capacity(specs.len());
for (i, spec) in specs.iter().enumerate() {
let mut vals: Vec<f64> = Vec::new();
let bar_data: Vec<&[f64]> = series
.iter()
.filter(|s| s.axis == i && matches!(s.kind, SeriesKind::Bars))
.filter_map(|s| match &s.data {
Data::Categorical(v) => Some(v.as_slice()),
_ => None,
})
.collect();
for s in series
.iter()
.filter(|s| s.axis == i && !matches!(s.kind, SeriesKind::Bars))
{
match &s.data {
Data::Categorical(v) => vals.extend(v),
Data::Points(p) => vals.extend(p.iter().map(|(_, y)| *y)),
Data::Formula(_) => {}
}
}
vals.extend(bubbles.iter().filter(|b| b.axis == i).map(|b| b.at.1));
if matches!(bars, BarMode::Stacked) {
let n = bar_data.iter().map(|v| v.len()).max().unwrap_or(0);
for c in 0..n {
vals.push(
bar_data
.iter()
.map(|v| v.get(c).copied().unwrap_or(0.0))
.sum(),
);
}
} else {
for v in &bar_data {
vals.extend(*v);
}
}
let scale = value_scale(&vals, !bar_data.is_empty(), spec)?;
out.push(ValueAxis {
side: matches!(spec.side, Side::Right)
.then_some(Side::Right)
.unwrap_or(Side::Left),
scale,
title: spec.title.clone(),
unit: spec.unit.clone(),
grid: clone_grid(&spec.grid),
primary: i == 0,
});
}
Ok(out)
}
fn value_scale(vals: &[f64], has_bars: bool, spec: &AxisSpec) -> Result<Scale, Error> {
let data_min = vals.iter().copied().fold(f64::INFINITY, f64::min);
let data_max = vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let (dmin, dmax) = if vals.is_empty() {
(0.0, 1.0)
} else {
(data_min, data_max)
};
if spec.log {
let lo = spec.range.as_ref().map_or(dmin, |(a, _)| end(a, dmin));
let hi = spec.range.as_ref().map_or(dmax, |(_, b)| end(b, dmax));
return log_scale(lo, hi, spec.range.is_some(), Span::empty());
}
let (min, max, rev) = match &spec.range {
Some((a, b)) => {
let lo = end(a, dmin);
let hi = end(b, dmax);
if (lo - hi).abs() < f64::EPSILON {
return Err(Error::at(Span::empty(), "'range' needs distinct ends"));
}
(lo.min(hi), lo.max(hi), lo > hi)
}
None => {
let lo = if has_bars || dmin >= 0.0 {
0.0
} else {
-scale::nice_max(-dmin)
};
let hi = scale::nice_max(dmax.max(0.0));
(lo, hi, false)
}
};
let ticks = axis_ticks(min, max, spec);
Ok(Scale::linear(min, max, rev, ticks))
}
fn numeric_scale(
xs: &[f64],
range: Option<(End, End)>,
spec_src: Option<&ResolvedInst>,
) -> Result<Scale, Error> {
let data_min = xs.iter().copied().fold(f64::INFINITY, f64::min);
let data_max = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let (dmin, dmax) = if xs.is_empty() {
(0.0, 1.0)
} else {
(data_min, data_max)
};
if spec_src.is_some_and(|a| read_log(a).unwrap_or(false)) {
let lo = range.as_ref().map_or(dmin, |(a, _)| end(a, dmin));
let hi = range.as_ref().map_or(dmax, |(_, b)| end(b, dmax));
let span = spec_src.map_or(Span::empty(), |a| a.span);
return log_scale(lo, hi, range.is_some(), span);
}
let (min, max, rev) = match range {
Some((a, b)) => {
let lo = end(&a, dmin);
let hi = end(&b, dmax);
(lo.min(hi), lo.max(hi), lo > hi)
}
None => (dmin, dmax, false),
};
let step = spec_src.and_then(|a| a.attrs.number("step"));
let explicit_ticks = spec_src.and_then(|a| number_list(a.attrs.get("ticks")));
let ticks = if let Some(t) = explicit_ticks {
t
} else if let Some(st) = step {
scale::ticks_by_step(min, max, st)
} else {
scale::nice_ticks(min, max)
};
Ok(Scale::linear(min, max, rev, ticks))
}
fn log_scale(lo: f64, hi: f64, has_range: bool, span: Span) -> Result<Scale, Error> {
if lo <= 0.0 || hi <= 0.0 {
return Err(Error::at(
span,
"a 'scale: log' axis needs a domain above 0",
));
}
let (a, b) = (lo.min(hi), lo.max(hi));
let (min, max) = if has_range {
(a, b)
} else {
(10f64.powf(a.log10().floor()), 10f64.powf(b.log10().ceil()))
};
Ok(Scale::log(min, max))
}
fn axis_ticks(min: f64, max: f64, spec: &AxisSpec) -> Vec<f64> {
if let Some(t) = &spec.ticks {
t.clone()
} else if let Some(step) = spec.step {
scale::ticks_by_step(min, max, step)
} else {
scale::nice_ticks(min, max)
}
}
fn axis_spec(inst: &ResolvedInst, side: Side) -> Result<AxisSpec<'_>, Error> {
Ok(AxisSpec {
id: inst.id.as_deref(),
side,
title: label_of(inst),
unit: read_unit(inst)?,
grid: read_grid(inst)?,
range: read_range(inst)?,
step: inst.attrs.number("step"),
ticks: number_list(inst.attrs.get("ticks")),
log: read_log(inst)?,
})
}
fn read_log(inst: &ResolvedInst) -> Result<bool, Error> {
match inst.attrs.get("scale") {
None => Ok(false),
Some(ResolvedValue::Ident(s)) if s == "linear" => Ok(false),
Some(ResolvedValue::Ident(s)) if s == "log" => Ok(true),
_ => Err(Error::at(inst.span, "'scale' is linear or log")),
}
}
fn read_side(inst: &ResolvedInst) -> Result<Option<Side>, Error> {
match inst.attrs.get("side") {
None => Ok(None),
Some(ResolvedValue::Ident(s)) => match s.as_str() {
"bottom" => Ok(Some(Side::Bottom)),
"top" => Ok(Some(Side::Top)),
"left" => Ok(Some(Side::Left)),
"right" => Ok(Some(Side::Right)),
_ => Err(Error::at(
inst.span,
"'side' is bottom, top, left, or right",
)),
},
_ => Err(Error::at(
inst.span,
"'side' is bottom, top, left, or right",
)),
}
}
fn read_grid(inst: &ResolvedInst) -> Result<Grid, Error> {
match inst.attrs.get("gridlines") {
None => Ok(Grid::Default),
Some(ResolvedValue::Ident(s)) if s == "none" => Ok(Grid::Off),
Some(v) => Ok(Grid::Color(v.clone())),
}
}
fn read_range(inst: &ResolvedInst) -> Result<Option<(End, End)>, Error> {
let Some(v) = inst.attrs.get("range") else {
return Ok(None);
};
let ResolvedValue::Tuple(items) = v else {
return Err(Error::at(
inst.span,
"'range' takes two ends: 'a b', 'a auto', or 'auto b'",
));
};
if items.len() != 2 {
return Err(Error::at(
inst.span,
"'range' takes two ends: 'a b', 'a auto', or 'auto b'",
));
}
Ok(Some((
read_end(&items[0], inst.span)?,
read_end(&items[1], inst.span)?,
)))
}
fn read_end(v: &ResolvedValue, span: Span) -> Result<End, Error> {
match v {
ResolvedValue::Number(n) => Ok(End::Num(*n)),
ResolvedValue::Ident(s) if s == "auto" => Ok(End::Auto),
_ => Err(Error::at(span, "a 'range' end is a number or 'auto'")),
}
}
fn end(e: &End, auto: f64) -> f64 {
match e {
End::Num(n) => *n,
End::Auto => auto,
}
}
fn read_unit(inst: &ResolvedInst) -> Result<Option<String>, Error> {
match inst.attrs.get("unit") {
None => Ok(None),
Some(ResolvedValue::String(s)) => Ok(Some(s.clone())),
_ => Err(Error::at(inst.span, "'unit' is a quoted string")),
}
}
fn read_curve(attrs: &AttrMap) -> Result<Curve, Error> {
match attrs.get("curve") {
None => Ok(Curve::Linear),
Some(ResolvedValue::Ident(s)) => match s.as_str() {
"linear" => Ok(Curve::Linear),
"smooth" => Ok(Curve::Smooth),
"step" => Ok(Curve::Step),
_ => Err(Error::at(
Span::empty(),
"'curve' is linear, smooth, or step",
)),
},
_ => Err(Error::at(
Span::empty(),
"'curve' is linear, smooth, or step",
)),
}
}
fn has_marker(inst: &ResolvedInst) -> bool {
inst.markers.start != MarkerKind::None || inst.markers.end != MarkerKind::None
}
fn read_categories(attrs: &AttrMap, span: Span) -> Result<Option<Vec<String>>, Error> {
let Some(v) = attrs.get("categories") else {
return Ok(None);
};
let mut out = Vec::new();
collect_strings(v, &mut out, span)?;
Ok(Some(out))
}
fn collect_strings(v: &ResolvedValue, out: &mut Vec<String>, span: Span) -> Result<(), Error> {
match v {
ResolvedValue::String(s) => out.push(s.clone()),
ResolvedValue::Tuple(items) | ResolvedValue::List(items) => {
for it in items {
collect_strings(it, out, span)?;
}
}
_ => return Err(Error::at(span, "'categories' is a list of quoted strings")),
}
Ok(())
}
fn number_list(v: Option<&ResolvedValue>) -> Option<Vec<f64>> {
match v? {
ResolvedValue::Number(n) => Some(vec![*n]),
ResolvedValue::Tuple(items) | ResolvedValue::List(items) => {
items.iter().map(ResolvedValue::as_number).collect()
}
_ => None,
}
}
fn numbers(items: &[ResolvedValue], span: Span) -> Result<Vec<f64>, Error> {
items.iter().map(|it| number(it, span)).collect()
}
fn number(v: &ResolvedValue, span: Span) -> Result<f64, Error> {
v.as_number()
.ok_or_else(|| Error::at(span, "'data' values must be numbers"))
}
fn label_of(inst: &ResolvedInst) -> Option<String> {
inst.label.clone().filter(|t| !t.is_empty()).or_else(|| {
inst.children
.iter()
.find(|c| c.kind == NodeKind::Text)
.and_then(|c| c.label.as_deref())
.filter(|t| !t.is_empty())
.map(str::to_string)
})
}
fn fill_color(attrs: &AttrMap) -> Option<ResolvedValue> {
real_color(attrs.get("fill"))
}
fn outline(attrs: &AttrMap) -> Option<(ResolvedValue, f64)> {
real_color(attrs.get("stroke")).map(|c| (c, attrs.number("stroke-width").unwrap_or(2.0)))
}
fn real_color(v: Option<&ResolvedValue>) -> Option<ResolvedValue> {
match v {
Some(ResolvedValue::Ident(s)) if s == "none" => None,
Some(ResolvedValue::LiveVar { name, .. }) if name == "stroke" || name == "fill" => None,
Some(other) => Some(other.clone()),
None => None,
}
}
fn live(name: &str) -> ResolvedValue {
ResolvedValue::LiveVar {
name: name.to_string(),
raw: false,
}
}
fn muted() -> ResolvedValue {
live("muted")
}
fn clone_grid(g: &Grid) -> Grid {
match g {
Grid::Default => Grid::Default,
Grid::Off => Grid::Off,
Grid::Color(c) => Grid::Color(c.clone()),
}
}