mod annot;
mod axis;
mod bars;
mod bubble;
mod labels;
mod marks;
mod model;
mod palette;
mod pie;
use super::prim;
mod project;
mod radial;
mod scale;
mod tooltip;
pub(super) use pie::{is_pie, layout_pie};
use crate::error::Error;
use crate::layout::{Bbox, PlacedNode};
use crate::resolve::{AttrMap, ResolvedInst, ResolvedValue};
use model::{Chart, Series, SeriesKind, Side};
use project::{Dir, Plot};
pub(super) const TITLE_SIZE: f64 = 13.0;
const AXIS_TITLE_SIZE: f64 = 11.0;
pub(super) const LABEL_SIZE: f64 = 11.0;
pub(super) fn is_chart(attrs: &AttrMap) -> bool {
matches!(attrs.get("layout"), Some(ResolvedValue::Ident(s)) if s == "chart")
}
pub(super) fn layout_chart(
inst: &ResolvedInst,
funcs: &crate::expr::FuncTable,
) -> Result<PlacedNode, Error> {
let chart = model::build(inst, funcs)?;
let square = chart.dir == Dir::Radial;
let w = inst
.attrs
.number("width")
.unwrap_or(if square { 280.0 } else { 360.0 });
let h = inst
.attrs
.number("height")
.unwrap_or(if square { 280.0 } else { 220.0 });
let plot = plot_rect(&chart, w, h);
let mut kids = Vec::new();
let mut reqs = Vec::new();
match plot.dir {
Dir::Radial => radial::gridlines(&plot, &chart, &mut kids),
Dir::Column => {
annot::band_shades(&plot, &chart, &mut kids);
axis::gridlines(&plot, &chart, &mut kids);
}
Dir::Row => axis::gridlines(&plot, &chart, &mut kids),
}
marks::areas(&plot, &chart, &mut kids);
bars::lay_out(&plot, &chart, &mut kids);
marks::lines(&plot, &chart, &mut kids);
marks::dots(&plot, &chart, &mut kids);
bubble::lay_out(&plot, &chart, &mut kids, &mut reqs);
match plot.dir {
Dir::Radial => radial::labels(&plot, &chart, &mut kids),
Dir::Row => axis::labels(&plot, &chart, &mut kids),
Dir::Column => {
annot::marks(&plot, &chart, &mut kids, &mut reqs);
axis::labels(&plot, &chart, &mut kids);
annot::band_ticks(&plot, &chart, &mut kids);
}
}
if let Some(t) = &chart.title {
kids.push(prim::text(
t,
0.0,
-h / 2.0 + 8.0 + TITLE_SIZE * 0.7,
TITLE_SIZE,
None,
true,
));
}
let legend = legend_entries(&chart);
if legend.len() >= 2 {
lay_out_legend(&legend, h / 2.0 - LABEL_SIZE * 0.9, &mut kids);
}
labels::collect_series(&plot, &chart, &mut reqs);
let lines = labels::series_lines(&plot, &chart);
kids.extend(labels::place(&reqs, &plot, &lines));
let kids = tooltip::apply(kids, chart.tooltip, w, h);
Ok(chart_box(inst, w, h, kids))
}
pub(super) fn chart_box(inst: &ResolvedInst, w: f64, h: f64, kids: Vec<PlacedNode>) -> PlacedNode {
prim::container(inst, Bbox::centered(w, h), kids)
}
fn plot_rect(chart: &Chart, w: f64, h: f64) -> Plot {
if chart.dir == Dir::Radial {
return radial_plot(chart, w, h);
}
if chart.dir == Dir::Row {
return row_plot(chart, w, h);
}
let left = nonzero(side_gutter(chart, false), 12.0);
let right = nonzero(side_gutter(chart, true), 12.0);
let title_h = title_reserve(chart.title.is_some(), chart.gap);
let value_title_h = if chart.values.iter().any(|a| a.title.is_some()) {
AXIS_TITLE_SIZE * 1.4
} else {
0.0
};
let x_title_h = if chart.x.title.is_some() {
AXIS_TITLE_SIZE * 1.4
} else {
0.0
};
let legend_h = legend_reserve(legend_entries(chart).len(), chart.gap);
let band_row = annot::x_band_row(chart);
Plot {
x0: -w / 2.0 + left,
x1: w / 2.0 - right,
y0: -h / 2.0 + 8.0 + title_h + value_title_h,
y1: h / 2.0 - 6.0 - LABEL_SIZE * 1.4 - band_row - x_title_h - legend_h,
dir: chart.dir,
}
}
fn row_plot(chart: &Chart, w: f64, h: f64) -> Plot {
let title_h = title_reserve(chart.title.is_some(), chart.gap);
let value_title_h = if chart.values.iter().any(|a| a.title.is_some()) {
AXIS_TITLE_SIZE * 1.4
} else {
0.0
};
let legend_h = legend_reserve(legend_entries(chart).len(), chart.gap);
let left = nonzero(domain_gutter(chart), 12.0);
Plot {
x0: -w / 2.0 + left,
x1: w / 2.0 - 12.0,
y0: -h / 2.0 + 8.0 + title_h,
y1: h / 2.0 - 6.0 - LABEL_SIZE * 1.4 - value_title_h - legend_h,
dir: Dir::Row,
}
}
fn domain_gutter(chart: &Chart) -> f64 {
let maxw = chart
.x
.labels
.iter()
.map(|l| prim::text_width(l, LABEL_SIZE))
.fold(0.0_f64, f64::max);
if maxw > 0.0 { maxw + 8.0 } else { 0.0 }
}
fn radial_plot(chart: &Chart, w: f64, h: f64) -> Plot {
let title_h = title_reserve(chart.title.is_some(), chart.gap);
let legend_h = legend_reserve(legend_entries(chart).len(), chart.gap);
let margin = LABEL_SIZE * 2.0;
let top = title_h + margin;
let avail_h = h - top - legend_h - margin;
let side = avail_h.min(w - 2.0 * margin).max(0.0);
let cy = -h / 2.0 + top + avail_h / 2.0;
Plot {
x0: -side / 2.0,
x1: side / 2.0,
y0: cy - side / 2.0,
y1: cy + side / 2.0,
dir: Dir::Radial,
}
}
fn side_gutter(chart: &Chart, right: bool) -> f64 {
let mut maxw = 0.0_f64;
let mut any = false;
for axis in chart
.values
.iter()
.filter(|a| matches!(a.side, Side::Right) == right)
{
any = true;
for &t in axis.scale.ticks() {
maxw = maxw.max(prim::text_width(&scale::label(t, &axis.unit), LABEL_SIZE));
}
}
if any { maxw + 10.0 } else { 0.0 }
}
fn nonzero(v: f64, fallback: f64) -> f64 {
if v > 0.0 { v } else { fallback }
}
pub(super) fn title_reserve(has_title: bool, gap: f64) -> f64 {
if has_title {
TITLE_SIZE * 1.2 + gap
} else {
0.0
}
}
pub(super) fn legend_reserve(entries: usize, gap: f64) -> f64 {
if entries >= 2 {
LABEL_SIZE * 0.7 + gap
} else {
0.0
}
}
pub(super) type LegendEntry = (String, ResolvedValue, Option<ResolvedValue>);
fn legend_entries(chart: &Chart) -> Vec<LegendEntry> {
chart
.series
.iter()
.filter_map(|s| {
s.label
.clone()
.map(|l| (l, s.color.clone(), swatch_edge(s)))
})
.collect()
}
fn swatch_edge(s: &Series) -> Option<ResolvedValue> {
let explicit = s.outline.as_ref().map(|(c, _)| c.clone());
match s.kind {
SeriesKind::Area => Some(explicit.unwrap_or_else(|| palette::deepen(&s.color))),
_ => explicit,
}
}
pub(super) fn lay_out_legend(entries: &[LegendEntry], cy: f64, out: &mut Vec<PlacedNode>) {
const SW: f64 = 11.0; const GAP: f64 = 5.0; const ITEM_GAP: f64 = 16.0; let widths: Vec<f64> = entries
.iter()
.map(|(l, _, _)| prim::text_width(l, LABEL_SIZE))
.collect();
let per: f64 = widths.iter().map(|w| SW + GAP + w).sum();
let total = per + ITEM_GAP * widths.len().saturating_sub(1) as f64;
let mut x = -total / 2.0;
for ((label, fill, edge), &tw) in entries.iter().zip(&widths) {
let mut swatch = prim::rect(x + SW / 2.0, cy, SW, SW, fill.clone(), 1.0);
prim::round(&mut swatch, 2.0); if let Some(edge) = edge {
prim::outline(&mut swatch, edge.clone(), 1.0); }
out.push(swatch);
out.push(prim::text(
label,
x + SW + GAP + tw / 2.0,
cy,
LABEL_SIZE,
None,
true,
));
x += SW + GAP + tw + ITEM_GAP;
}
}
#[cfg(test)]
mod tests {
fn svg(src: &str) -> String {
crate::compile_str(src).expect("compile")
}
fn layout_err(src: &str) -> String {
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(&toks).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
crate::layout::layout(&program)
.err()
.expect("expected a layout error")
.to_string()
}
#[test]
fn bars_chart_lowers_to_axis_bars_legend_and_title() {
let s = svg(
"|chart| \"T\" { categories: \"a\" \"b\" } [\n |bars| \"S1\" { data: 3 6 }\n |bars| \"S2\" { data: 4 2 }\n]\n",
);
assert!(s.contains("lini-chart"), "chart container class: {s}");
assert!(s.contains("var(--lini-rose-soft)"), "series 0 hue: {s}");
assert!(s.contains("var(--lini-teal-soft)"), "series 1 hue: {s}");
assert!(!s.contains("var(--lini-red)"), "red is reserved: {s}");
assert!(s.contains("var(--lini-grid)"), "gridlines: {s}");
assert!(s.contains("<title>a · S1: 3</title>"), "bar title: {s}");
assert!(s.contains(">T</text>"), "chart title text: {s}");
}
#[test]
fn a_line_series_draws_a_polyline() {
let s = svg("|chart| { categories: \"a\" \"b\" \"c\" } [\n |line| { data: 3 6 4 }\n]\n");
assert!(s.contains("<polyline"), "polyline: {s}");
}
#[test]
fn a_dots_series_over_points_draws_ellipses() {
let s = svg(
"|chart| [\n |axis| { side: bottom }\n |axis| { side: left }\n |dots| { data: 1 5, 2 3, 3 8 }\n]\n",
);
assert!(s.contains("<ellipse"), "dots render as ellipses: {s}");
}
#[test]
fn an_explicit_fill_overrides_the_palette_walk() {
let s = svg("|chart| { categories: \"a\" } [\n |bars| { data: 5; fill: --teal }\n]\n");
assert!(s.contains("var(--lini-teal)"), "explicit fill kept: {s}");
assert!(!s.contains("var(--lini-rose)"), "palette not walked: {s}");
}
#[test]
fn a_bar_radius_rounds_the_rect() {
let s = svg("|chart| { categories: \"a\" } [\n |bars| { data: 5; radius: 6 }\n]\n");
assert!(
s.contains("rx=\"6\""),
"explicit bar radius rounds the rect: {s}"
);
let d = svg("|chart| { categories: \"a\" } [\n |bars| { data: 5 }\n]\n");
assert!(
d.contains("rx=\"2\""),
"the default bar radius rounds the rect: {d}"
);
}
#[test]
fn a_bar_stroke_draws_an_outline_without_recoloring_the_fill() {
let s = svg("|chart| { categories: \"a\" } [\n |bars| { data: 5; stroke: --sky }\n]\n");
assert!(
s.contains("var(--lini-rose-soft)"),
"the fill stays the palette soft tier: {s}"
);
assert!(
s.contains("var(--lini-sky)"),
"the stroke draws as an outline: {s}"
);
}
#[test]
fn bars_default_to_an_outlined_look() {
let s = svg("|chart| { categories: \"a\" } [\n |bars| { data: 5 }\n]\n");
assert!(s.contains("var(--lini-rose-soft)"), "soft fill: {s}");
assert!(s.contains("var(--lini-rose-deep)"), "deep edge: {s}");
}
#[test]
fn a_bar_stroke_none_opts_out_of_the_edge() {
let s = svg("|chart| { categories: \"a\" } [\n |bars| { data: 5; stroke: none }\n]\n");
assert!(s.contains("var(--lini-rose-soft)"), "soft fill stays: {s}");
assert!(!s.contains("var(--lini-rose-deep)"), "no deep edge: {s}");
}
#[test]
fn a_slice_stroke_outlines_without_recoloring_the_fill() {
let s = svg(
"|pie| [\n |slice| \"a\" { value: 1; stroke: --sky }\n |slice| \"b\" { value: 1 }\n]\n",
);
assert!(
s.contains("var(--lini-rose-soft)"),
"slice 0 fill stays the palette soft walk: {s}"
);
assert!(
s.contains("var(--lini-sky)"),
"slice 0 stroke draws as an outline: {s}"
);
}
#[test]
fn the_chart_gap_tunes_the_title_inset() {
let tight = svg("|chart| \"T\" { categories: \"a\"; gap: 0 } [\n |bars| { data: 5 }\n]\n");
let loose =
svg("|chart| \"T\" { categories: \"a\"; gap: 60 } [\n |bars| { data: 5 }\n]\n");
assert_ne!(
tight, loose,
"the chart 'gap' changes the title / plot spacing"
);
}
#[test]
fn a_dual_axis_chart_binds_series_by_id() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |axis#n| { side: left }\n |axis#p| { side: right }\n |bars| { data: 10 20; axis: n }\n |line| { data: 4 9; axis: p }\n]\n",
);
assert!(s.contains("<line "), "the 2-point line: {s}");
assert!(s.contains(">20</text>"), "left axis from bars: {s}");
assert!(s.contains(">8</text>"), "right axis from line: {s}");
}
#[test]
fn an_unknown_axis_id_errors_with_a_suggestion() {
let e = layout_err(
"|chart| { categories: \"a\" } [\n |axis#v| { side: left }\n |line| { data: 1; axis: nope }\n]\n",
);
assert!(e.contains("axis 'nope' not found"), "{e}");
assert!(e.contains("'v'"), "suggests the known id: {e}");
}
#[test]
fn empty_chart_errors() {
assert!(layout_err("|chart| \"T\"\n").contains("at least one series"));
}
#[test]
fn data_count_must_match_categories() {
let e = layout_err("|chart| { categories: \"a\" \"b\" } [\n |bars| { data: 1 2 3 }\n]\n");
assert!(e.contains("3 values but the chart has 2 categories"), "{e}");
}
#[test]
fn data_and_fn_together_error() {
let e = layout_err("|chart| { categories: \"a\" } [\n |bars| { data: 1; fn: `2` }\n]\n");
assert!(e.contains("not both"), "{e}");
}
#[test]
fn a_non_series_child_is_rejected() {
let e = layout_err("|chart| [\n |box| \"x\"\n]\n");
assert!(e.contains("series"), "{e}");
}
#[test]
fn a_fn_series_samples_a_curve_over_the_x_domain() {
let s = svg(
"|chart| [\n |axis| { side: bottom; range: 0 10 }\n |axis| { side: left }\n |line| { fn: `x*x`; samples: 12 }\n]\n",
);
assert!(s.contains("<polyline"), "sampled fn polyline: {s}");
assert!(
s.contains(">100</text>"),
"value axis fits the sampled data: {s}"
);
}
#[test]
fn an_area_series_fills_a_polygon() {
let s = svg("|chart| { categories: \"a\" \"b\" \"c\" } [\n |area| { data: 3 6 4 }\n]\n");
assert!(s.contains("<polygon"), "area fill: {s}");
}
#[test]
fn a_log_axis_draws_decade_ticks() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |axis| { side: left; scale: log }\n |bars| { data: 10 1000 }\n]\n",
);
assert!(s.contains(">100</text>"), "decade tick: {s}");
assert!(s.contains(">1000</text>"), "decade tick: {s}");
}
#[test]
fn a_log_axis_over_a_non_positive_domain_errors() {
let e = layout_err(
"|chart| { categories: \"a\" } [\n |axis| { side: left; scale: log; range: -1 10 }\n |bars| { data: 5 }\n]\n",
);
assert!(e.contains("domain above 0"), "{e}");
}
#[test]
fn a_smooth_curve_resamples_densely() {
let s = svg(
"|chart| { categories: \"a\" \"b\" \"c\" \"d\" } [\n |line| { data: 1 8 2 6; curve: smooth }\n]\n",
);
let pts = s
.split("<polyline points=\"")
.nth(1)
.and_then(|t| t.split('"').next())
.unwrap_or("");
assert!(
pts.split(' ').count() > 20,
"smooth resamples densely, got {} points",
pts.split(' ').count()
);
}
#[test]
fn a_fn_list_without_bands_reports_the_mismatch() {
let e = layout_err(
"|chart| [\n |axis| { side: bottom; range: 0 1 }\n |axis| { side: left }\n |line| { fn: `1` `2` }\n]\n",
);
assert!(e.contains("2 formulas"), "{e}");
assert!(e.contains("0 bands"), "{e}");
}
#[test]
fn a_filled_band_shades_the_plot_and_labels_it() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |bars| { data: 5 8 }\n |band| \"zone\" { span: 0 1; fill: --amber }\n]\n",
);
assert!(s.contains("var(--lini-amber)"), "band shade tint: {s}");
assert!(s.contains("opacity"), "the shade is translucent: {s}");
assert!(s.contains(">zone</text>"), "band tick label: {s}");
}
#[test]
fn an_unfilled_band_draws_a_divider_not_a_shade() {
let s = svg(
"|chart| { categories: \"a\" \"b\" \"c\" } [\n |bars| { data: 5 8 6 }\n |band| \"L\" { span: 0 1 }\n |band| \"R\" { span: 1 3 }\n]\n",
);
assert!(
s.contains(">L</text>") && s.contains(">R</text>"),
"band ticks: {s}"
);
assert!(
!s.contains("opacity"),
"no shade is drawn for an unfilled band: {s}"
);
}
#[test]
fn a_segmented_fn_draws_one_polyline_across_the_bands() {
let s = svg(
"|chart| [\n |axis| { side: bottom }\n |axis| { side: left }\n |band| { span: 0 1 }\n |band| { span: 1 2 }\n |line| { fn: `u` `1-u` }\n]\n",
);
assert!(s.contains("<polyline"), "segmented curve polyline: {s}");
}
#[test]
fn a_fn_list_length_must_match_the_band_count() {
let e = layout_err(
"|chart| [\n |axis| { side: bottom }\n |axis| { side: left }\n |band| { span: 0 1 }\n |line| { fn: `1` `2` `3` }\n]\n",
);
assert!(e.contains("3 formulas"), "{e}");
assert!(e.contains("1 bands"), "{e}");
}
#[test]
fn a_mark_draws_a_reference_line_with_its_label() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |axis#v| { side: left }\n |bars| { data: 5 8 }\n |mark| \"max\" { at: 6; axis: v; stroke: --red }\n]\n",
);
assert!(
s.contains("var(--lini-red)"),
"the reference line is the mark's stroke: {s}"
);
assert!(s.contains(">max</text>"), "the mark label: {s}");
}
#[test]
fn a_mark_point_draws_a_dot_and_a_label() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |axis#v| { side: left }\n |bars| { data: 5 8 }\n |mark| \"pt\" { at: 1 6; axis: v }\n]\n",
);
assert!(s.contains("<ellipse"), "the point's dot: {s}");
assert!(s.contains(">pt</text>"), "the point's label: {s}");
}
#[test]
fn marker_none_suppresses_the_point_dot() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |axis#v| { side: left }\n |bars| { data: 5 8 }\n |mark| \"lbl\" { at: 1 6; axis: v; marker: none }\n]\n",
);
assert!(s.contains(">lbl</text>"), "the label still draws: {s}");
assert!(!s.contains("<ellipse"), "no dot when 'marker: none': {s}");
}
#[test]
fn a_mark_needs_an_axis() {
let e = layout_err(
"|chart| { categories: \"a\" } [\n |bars| { data: 5 }\n |mark| \"x\" { at: 3 }\n]\n",
);
assert!(e.contains("needs 'axis:'"), "{e}");
}
#[test]
fn a_mark_at_takes_one_or_two_values() {
let e = layout_err(
"|chart| { categories: \"a\" } [\n |axis#v| { side: left }\n |bars| { data: 5 }\n |mark| \"x\" { at: 1 2 3; axis: v }\n]\n",
);
assert!(e.contains("one value"), "{e}");
}
#[test]
fn stacked_bars_fit_the_per_category_sum() {
let s = svg(
"|chart| { categories: \"a\" \"b\"; bars: stacked } [\n |bars| { data: 3 4 }\n |bars| { data: 5 6 }\n]\n",
);
assert!(
s.contains(">10</text>"),
"value axis fits the stack sum: {s}"
);
}
#[test]
fn overlay_bars_are_translucent() {
let s = svg(
"|chart| { categories: \"a\" \"b\"; bars: overlay } [\n |bars| { data: 3 4 }\n |bars| { data: 7 6 }\n]\n",
);
assert!(s.contains("opacity"), "overlay bars carry an opacity: {s}");
}
#[test]
fn a_radial_line_draws_a_closed_radar_with_spoke_labels() {
let s = svg(
"|chart| { direction: radial; categories: \"a\" \"b\" \"c\" } [\n |axis| { range: 0 5 }\n |line| { data: 5 3 4 }\n]\n",
);
assert!(s.contains("<polyline"), "the radar loop: {s}");
assert!(s.contains(">a</text>"), "a spoke (category) label: {s}");
}
#[test]
fn radial_bars_draw_wedge_polygons() {
let s = svg(
"|chart| { direction: radial; categories: \"a\" \"b\" \"c\" } [\n |axis| { range: 0 10 }\n |bars| { data: 8 5 9 }\n]\n",
);
assert!(s.contains("<polygon"), "wedge polygons: {s}");
}
#[test]
fn a_side_on_a_radial_axis_errors() {
let e = layout_err(
"|chart| { direction: radial; categories: \"a\" \"b\" } [\n |axis| { side: left; range: 0 5 }\n |line| { data: 3 4 }\n]\n",
);
assert!(e.contains("radial"), "{e}");
}
#[test]
fn a_row_chart_lays_categories_left_and_values_below() {
let s = svg(
"|chart| { direction: row; categories: \"a\" \"b\" } [\n |axis| \"v\" { side: bottom }\n |bars| { data: 5 10 }\n]\n",
);
assert!(s.contains("<rect"), "horizontal bars: {s}");
assert!(s.contains(">a</text>"), "a category label (left): {s}");
assert!(s.contains(">10</text>"), "a value tick (below): {s}");
}
#[test]
fn a_row_line_projects_through_the_same_builder() {
let s = svg(
"|chart| { direction: row; categories: \"a\" \"b\" \"c\" } [\n |line| { data: 3 6 4 }\n]\n",
);
assert!(
s.contains("<polyline"),
"the row line reuses the cartesian builder: {s}"
);
}
#[test]
fn an_unknown_direction_errors() {
let e = layout_err(
"|chart| { direction: sideways; categories: \"a\" } [\n |bars| { data: 5 }\n]\n",
);
assert!(e.contains("column, row, or radial"), "{e}");
}
#[test]
fn a_pie_draws_slice_wedges_and_a_legend() {
let s =
svg("|pie| \"T\" [\n |slice| \"a\" { value: 3 }\n |slice| \"b\" { value: 1 }\n]\n");
assert!(s.contains("<polygon"), "slice wedges: {s}");
assert!(
s.contains("var(--lini-rose-soft)"),
"slice 0 walks the palette (soft): {s}"
);
assert!(
s.contains("var(--lini-teal-soft)"),
"slice 1 walks the palette (soft): {s}"
);
assert!(s.contains(">a</text>"), "a legend label: {s}");
}
#[test]
fn a_non_slice_child_of_a_pie_errors() {
let e = layout_err("|pie| [\n |bars| { data: 1 }\n]\n");
assert!(e.contains("'|slice|' only"), "{e}");
}
#[test]
fn an_empty_pie_errors() {
assert!(layout_err("|pie| \"T\"\n").contains("at least one '|slice|'"));
}
#[test]
fn a_negative_slice_value_errors() {
let e = layout_err("|pie| [\n |slice| { value: -1 }\n]\n");
assert!(e.contains("≥ 0"), "{e}");
}
#[test]
fn a_pie_summing_to_zero_errors() {
let e = layout_err("|pie| [\n |slice| { value: 0 }\n |slice| { value: 0 }\n]\n");
assert!(e.contains("sum to zero"), "{e}");
}
#[test]
fn a_hole_out_of_range_errors() {
let e = layout_err("|pie| { hole: 1.5 } [\n |slice| { value: 1 }\n]\n");
assert!(e.contains("fraction 0..1"), "{e}");
}
#[test]
fn bubbles_render_as_ovals_with_a_title_floor() {
let s = svg(
"|chart| [\n |axis| { side: bottom }\n |axis| { side: left }\n |bubble| \"A\" { at: 1 2; value: 4 }\n |bubble| \"B\" { at: 3 4; value: 16 }\n]\n",
);
assert!(s.contains("<ellipse"), "bubbles are ovals: {s}");
assert!(
s.contains("<title>B: 16</title>"),
"the bubble <title> floor: {s}"
);
}
#[test]
fn a_bubble_needs_at_and_value() {
let e = layout_err(
"|chart| [\n |axis| { side: bottom }\n |axis| { side: left }\n |bubble| \"A\" { at: 1 2 }\n]\n",
);
assert!(e.contains("needs 'at:' (x y) and 'value:'"), "{e}");
}
#[test]
fn auto_tooltips_add_a_hover_card_over_the_title_floor() {
let s = svg("|chart| { categories: \"a\" } [\n |bars| { data: 5 }\n]\n");
assert!(s.contains("lini-chart-tip"), "the hover card: {s}");
assert!(
s.contains("<title>a: 5</title>"),
"the title floor stays: {s}"
);
assert!(
s.contains(":hover ~ .lini-tip-0"),
"the reveal rule links the mark to its card: {s}"
);
assert!(s.contains("lini-hit-0"), "the hovered mark is tagged: {s}");
}
#[test]
fn tooltip_none_drops_the_floor() {
let s = svg("|chart| { categories: \"a\"; tooltip: none } [\n |bars| { data: 5 }\n]\n");
assert!(!s.contains("<title>"), "no title floor: {s}");
assert!(!s.contains("lini-chart-tip"), "no card: {s}");
}
#[test]
fn tags_draw_inline_labels_under_auto() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |line| { data: 3 6; tags: \"lo\" \"hi\" }\n]\n",
);
assert!(s.contains("lini-chart-label"), "inline label class: {s}");
assert!(
s.contains(">lo</text>") && s.contains(">hi</text>"),
"tag text: {s}"
);
assert!(
s.contains("pointer-events: none"),
"inline labels pass hover through: {s}"
);
}
#[test]
fn tooltip_hover_keeps_tags_off_the_plot() {
let s = svg(
"|chart| { categories: \"a\" \"b\"; tooltip: hover } [\n |bars| { data: 3 6; tags: \"lo\" \"hi\" }\n]\n",
);
assert!(!s.contains("lini-chart-label"), "no inline label: {s}");
assert!(s.contains("lini-chart-tip"), "the hover card stays: {s}");
}
#[test]
fn a_series_tooltip_overrides_the_chart_default() {
let s = svg(
"|chart| { categories: \"a\" \"b\"; tooltip: hover } [\n |line| { data: 3 6; tags: \"lo\" \"hi\"; tooltip: always }\n]\n",
);
assert!(
s.contains("lini-chart-label"),
"series override shows inline: {s}"
);
}
#[test]
fn an_arrow_marker_on_a_series_errors() {
let e = layout_err(
"|chart| { categories: \"a\" \"b\" } [\n |line| { data: 3 6; marker: arrow }\n]\n",
);
assert!(e.contains("no centred form"), "{e}");
assert!(e.contains("dot, circle, or diamond"), "{e}");
}
#[test]
fn a_circle_marker_is_bigger_than_a_dot() {
let c = svg(
"|chart| { categories: \"a\" \"b\" } [\n |line| { data: 3 6; marker: circle }\n]\n",
);
let d =
svg("|chart| { categories: \"a\" \"b\" } [\n |line| { data: 3 6; marker: dot }\n]\n");
assert!(c.contains("rx=\"5.5\""), "circle marker radius: {c}");
assert!(d.contains("rx=\"2.5\""), "dot marker radius: {d}");
}
#[test]
fn a_diamond_marker_draws_a_rhombus() {
let s = svg(
"|chart| { categories: \"a\" \"b\" } [\n |line| { data: 3 6; marker: diamond }\n]\n",
);
assert!(s.contains("<polygon"), "diamond marker is a polygon: {s}");
}
#[test]
fn data_text_is_normal_weight_chrome_is_bold() {
let s = svg(
"|chart| \"Cost\" { categories: \"a\" \"b\" } [\n |bars| \"A\" { data: 5 8 }\n |bars| \"B\" { data: 3 4 }\n]\n",
);
assert!(
s.contains("font-weight: bold; font-size: 13px\">Cost</text>"),
"title bold: {s}"
);
assert!(
s.contains("font-weight: bold; font-size: 11px\">A</text>"),
"legend bold: {s}"
);
assert!(
s.contains("font-weight: normal; font-size: 11px\">a</text>"),
"axis tick normal: {s}"
);
}
#[test]
fn tags_count_must_match_the_data() {
let e = layout_err(
"|chart| { categories: \"a\" \"b\" } [\n |line| { data: 3 6; tags: \"only\" }\n]\n",
);
assert!(e.contains("1 labels but the series has 2"), "{e}");
}
#[test]
fn tags_on_a_fn_series_error() {
let e = layout_err(
"|chart| [\n |axis| { side: bottom; range: 0 10 }\n |axis| { side: left }\n |line| { fn: `x`; tags: \"a\" \"b\" }\n]\n",
);
assert!(e.contains("needs explicit 'data'"), "{e}");
}
}