use super::*;
pub(super) fn read_bubble(
inst: &ResolvedInst,
index: usize,
specs: &[AxisSpec],
chart_tip: Tooltip,
) -> 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),
tooltip: super::tooltip::read_or(&inst.attrs, chart_tip)?,
})
}
pub(super) fn read_series(
inst: &ResolvedInst,
index: usize,
value_specs: &[AxisSpec],
categories: &Option<Vec<String>>,
chart_tip: Tooltip,
_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 => "-soft",
};
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);
let marker = chart_marker(inst)?;
let marker = if matches!(kind, SeriesKind::Dots) && marker == MarkerKind::None {
MarkerKind::Dot
} else {
marker
};
let labels = read_labels(inst, &data)?;
let tooltip = super::tooltip::read_or(&inst.attrs, chart_tip)?;
let tag_color = real_color(inst.attrs.get("color")).unwrap_or_else(muted);
let edge = match kind {
SeriesKind::Bars => fill_outline(&inst.attrs, &color),
_ => outline(&inst.attrs),
};
Ok(Series {
kind,
data,
label: label_of(inst),
color,
axis,
marker,
labels,
tooltip,
tag_color,
curve: read_curve(&inst.attrs)?,
stroke_style: inst.attrs.get("stroke-style").cloned(),
outline: edge,
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"),
})
}
pub(super) 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> {
let Some(ResolvedValue::List(items)) = inst.attrs.get("data") else {
return Err(Error::at(inst.span, "'data' must be a list of numbers"));
};
if items.iter().all(|it| it.as_number().is_some()) {
return Ok(Data::Categorical(numbers(items, inst.span)?));
}
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)?));
}
ResolvedValue::Tuple(_) => {
return Err(Error::at(
inst.span,
"'data' takes comma-separated values — 'data: 9, 15, 24'",
));
}
_ => return Err(Error::at(inst.span, "point data is 'x y' pairs")),
}
}
if matches!(kind, SeriesKind::Bars) {
return Err(Error::at(
inst.span,
"'|bars|' takes categorical data ('data: 9, 15, 24'), not 'x y' points",
));
}
Ok(Data::Points(pts))
}
fn read_labels(inst: &ResolvedInst, data: &Data) -> Result<Vec<String>, Error> {
let Some(v) = inst.attrs.get("labels") else {
return Ok(Vec::new());
};
let mut labels = Vec::new();
collect_strings("labels", v, &mut labels, inst.span)?;
let n = match data {
Data::Categorical(values) => values.len(),
Data::Points(p) => p.len(),
Data::Formula(_) => {
return Err(Error::at(
inst.span,
"'labels' needs explicit 'data' — a sampled 'fn' has no points to label",
));
}
};
if labels.len() != n {
return Err(Error::at(
inst.span,
format!(
"'labels' has {} entries but the series has {} data points",
labels.len(),
n
),
));
}
Ok(labels)
}
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",
)),
}
}
pub(super) fn chart_marker(inst: &ResolvedInst) -> Result<MarkerKind, Error> {
let kind = if inst.markers.start != MarkerKind::None {
inst.markers.start
} else {
inst.markers.end
};
match kind {
MarkerKind::Arrow | MarkerKind::Crow => {
let name = if kind == MarkerKind::Arrow {
"arrow"
} else {
"crow"
};
Err(Error::at(
inst.span,
format!(
"'marker: {name}' has no centred form on a chart — use dot, circle, or diamond"
),
))
}
k => Ok(k),
}
}
pub(super) fn collect_strings(
name: &str,
v: &ResolvedValue,
out: &mut Vec<String>,
span: Span,
) -> Result<(), Error> {
let bad = || {
Error::at(
span,
format!("'{name}' takes comma-separated quoted strings — '{name}: \"a\", \"b\"'"),
)
};
let ResolvedValue::List(items) = v else {
return Err(bad());
};
for it in items {
match it {
ResolvedValue::String(s) => out.push(s.clone()),
_ => return Err(bad()),
}
}
Ok(())
}