1use crate::compile;
7use crate::error::Error;
8use crate::ingest::{self, DataDoc};
9use crate::raster::{self, Marker};
10use crate::spec::Spec;
11use crate::theme::Theme;
12
13pub struct RenderOptions {
14 pub width: Option<usize>,
16 pub height: Option<usize>,
18 pub marker: Marker,
19 pub bar_style: BarStyle,
20 pub theme: Theme,
21 pub color: bool,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum BarStyle {
29 Dots,
30 Blocks,
31}
32
33#[derive(Debug)]
34pub struct Rendered {
35 pub text: String,
36 pub meta: serde_json::Value,
37}
38
39pub fn render(spec: &Spec, data: Option<DataDoc>, opts: &RenderOptions) -> Result<Rendered, Error> {
40 let table = ingest::resolve(spec, data)?;
44 let copts = compile::CompileOptions {
45 width: opts.width,
46 height: opts.height,
47 theme: opts.theme.clone(),
48 };
49 let scene = compile::compile(spec, &table, &copts)?;
50 let ropts = raster::RasterOptions {
51 marker: opts.marker,
52 bar_style: opts.bar_style,
53 color: opts.color,
54 };
55 Ok(raster::rasterize(&scene, &ropts))
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 fn opts() -> RenderOptions {
63 RenderOptions {
64 width: None,
65 height: None,
66 marker: Marker::Braille,
67 bar_style: BarStyle::Dots,
68 theme: crate::theme::by_name("benday").unwrap(),
69 color: false,
70 }
71 }
72
73 fn spec(json: &str) -> Spec {
74 serde_json::from_str(json).unwrap()
75 }
76
77 fn is_braille(c: char) -> bool {
78 ('\u{2800}'..='\u{28FF}').contains(&c)
79 }
80
81 #[test]
82 fn bar_chart_dots_by_default() {
83 let s = spec(
84 r#"{"data":{"values":[{"m":"jan","v":3},{"m":"feb","v":7}]},
85 "mark":"bar","encoding":{"x":{"field":"m"},"y":{"field":"v"}}}"#,
86 );
87 let out = render(&s, None, &opts()).unwrap();
88 assert!(out.text.chars().any(is_braille));
89 assert!(out.text.contains("jan"));
90 }
91
92 #[test]
93 fn bar_chart_blocks_style() {
94 let s = spec(
95 r#"{"data":{"values":[{"m":"jan","v":3},{"m":"feb","v":7}]},
96 "mark":"bar","encoding":{"x":{"field":"m"},"y":{"field":"v"}}}"#,
97 );
98 let out = render(
99 &s,
100 None,
101 &RenderOptions {
102 bar_style: BarStyle::Blocks,
103 ..opts()
104 },
105 )
106 .unwrap();
107 assert!(out.text.contains('█'));
108 }
109
110 #[test]
111 fn line_chart_smoke() {
112 let s = spec(
113 r#"{"data":{"values":[{"x":0,"y":1},{"x":1,"y":4},{"x":2,"y":2}]},
114 "mark":"line","encoding":{"x":{"field":"x"},"y":{"field":"y"}}}"#,
115 );
116 let out = render(&s, None, &opts()).unwrap();
117 assert!(out.text.chars().any(is_braille));
118 }
119
120 #[test]
121 fn missing_field_is_actionable() {
122 let s = spec(
123 r#"{"data":{"values":[{"month":"jan","sales":3}]},
124 "mark":"bar","encoding":{"x":{"field":"month"},"y":{"field":"revenue"}}}"#,
125 );
126 let err = render(&s, None, &opts()).unwrap_err();
127 let msg = err.to_string();
128 assert_eq!(err.kind(), "data");
129 assert!(msg.contains("revenue") && msg.contains("available fields"));
130 assert!(msg.contains("month") && msg.contains("sales"));
131 }
132
133 #[test]
134 fn aggregate_on_categorical_channel_is_rejected() {
135 let s = spec(
139 r#"{"data":{"values":[{"m":"jan","v":3}]},
140 "mark":"bar",
141 "encoding":{"x":{"field":"m","aggregate":"sum"},"y":{"field":"v"}}}"#,
142 );
143 let err = render(&s, None, &opts()).unwrap_err();
144 assert_eq!(err.kind(), "spec");
145 assert!(err.to_string().contains("put `aggregate` on encoding.y"));
146 }
147
148 #[test]
149 fn bar_color_grouping_produces_grouped_series() {
150 let s = spec(
153 r#"{"data":{"values":[
154 {"m":"jan","v":3,"region":"west"},{"m":"jan","v":2,"region":"east"},
155 {"m":"feb","v":4,"region":"west"},{"m":"feb","v":5,"region":"east"}]},
156 "mark":"bar",
157 "encoding":{"x":{"field":"m"},"y":{"field":"v"},"color":{"field":"region"}}}"#,
158 );
159 let out = render(&s, None, &opts()).unwrap();
160 let series = out.meta["series"]
161 .as_array()
162 .expect("grouped bars carry series");
163 assert_eq!(series.len(), 2);
164 assert_eq!(series[0]["name"], "west");
165 assert_eq!(series[1]["name"], "east");
166 }
167}