1mod log_macros;
2
3use clap::Parser;
4use core::fmt::Arguments;
5use easy_error::{self, ResultExt};
6use serde::Deserialize;
7use std::{
8 error::Error,
9 fs::File,
10 io::{self, Read, Write},
11 path::PathBuf,
12};
13use svg::{node::element::*, node::*, Document};
14
15pub trait LineChartLog {
16 fn output(self: &Self, args: Arguments);
17 fn warning(self: &Self, args: Arguments);
18 fn error(self: &Self, args: Arguments);
19}
20
21pub struct LineChartTool<'a> {
22 log: &'a dyn LineChartLog,
23}
24
25#[derive(Parser)]
26#[clap(version, about, long_about = None)]
27struct Cli {
28 #[clap(value_name = "INPUT_FILE")]
30 input_file: Option<PathBuf>,
31
32 #[clap(value_name = "OUTPUT_FILE")]
34 output_file: Option<PathBuf>,
35}
36
37impl Cli {
38 fn get_output(&self) -> Result<Box<dyn Write>, Box<dyn Error>> {
39 match self.output_file {
40 Some(ref path) => File::create(path)
41 .context(format!(
42 "Unable to create file '{}'",
43 path.to_string_lossy()
44 ))
45 .map(|f| Box::new(f) as Box<dyn Write>)
46 .map_err(|e| Box::new(e) as Box<dyn Error>),
47 None => Ok(Box::new(io::stdout())),
48 }
49 }
50
51 fn get_input(&self) -> Result<Box<dyn Read>, Box<dyn Error>> {
52 match self.input_file {
53 Some(ref path) => File::open(path)
54 .context(format!("Unable to open file '{}'", path.to_string_lossy()))
55 .map(|f| Box::new(f) as Box<dyn Read>)
56 .map_err(|e| Box::new(e) as Box<dyn Error>),
57 None => Ok(Box::new(io::stdin())),
58 }
59 }
60}
61
62#[derive(Deserialize, Debug, Clone)]
63pub struct ChartData {
64 pub title: String,
65 pub units: String,
66 pub data: Vec<ItemData>,
67}
68
69#[derive(Deserialize, Debug, Clone)]
70pub struct ItemData {
71 pub key: String,
72 pub value: f64,
73}
74
75#[derive(Debug)]
76struct Gutter {
77 left: f64,
78 top: f64,
79 right: f64,
80 bottom: f64,
81}
82
83#[derive(Debug)]
84struct RenderData {
85 title: String,
86 units: String,
87 plot_width: f64,
88 y_axis_height: f64,
89 y_axis_range: (f64, f64),
90 y_axis_interval: f64,
91 gutter: Gutter,
92 styles: Vec<String>,
93 tuples: Vec<(String, f64)>,
94}
95
96impl<'a> LineChartTool<'a> {
97 pub fn new(log: &'a dyn LineChartLog) -> LineChartTool {
98 LineChartTool { log }
99 }
100
101 pub fn run(
102 self: &mut Self,
103 args: impl IntoIterator<Item = std::ffi::OsString>,
104 ) -> Result<(), Box<dyn Error>> {
105 let cli = match Cli::try_parse_from(args) {
106 Ok(m) => m,
107 Err(err) => {
108 output!(self.log, "{}", err.to_string());
109 return Ok(());
110 }
111 };
112
113 let chart_data = Self::read_chart_file(cli.get_input()?)?;
114 let render_data = self.process_chart_data(&chart_data)?;
115 let document = self.render_chart(&render_data)?;
116
117 Self::write_svg_file(cli.get_output()?, &document)?;
118
119 Ok(())
120 }
121
122 fn read_chart_file(mut reader: Box<dyn Read>) -> Result<ChartData, Box<dyn Error>> {
123 let mut content = String::new();
124
125 reader.read_to_string(&mut content)?;
126
127 let chart_data: ChartData = json5::from_str(&content)?;
128
129 Ok(chart_data)
130 }
131
132 fn write_svg_file(writer: Box<dyn Write>, document: &Document) -> Result<(), Box<dyn Error>> {
133 svg::write(writer, document)?;
134
135 Ok(())
136 }
137
138 fn process_chart_data(self: &Self, cd: &ChartData) -> Result<RenderData, Box<dyn Error>> {
139 let mut tuples = vec![];
140 let mut y_axis_range: (f64, f64) = (f64::MAX, f64::MIN);
141
142 for item_data in cd.data.iter() {
143 let value = item_data.value;
144
145 if value < y_axis_range.0 {
146 y_axis_range.0 = value;
147 } else if value > y_axis_range.1 {
148 y_axis_range.1 = value;
149 }
150
151 tuples.push((item_data.key.to_owned(), item_data.value));
152 }
153
154 let plot_width = 50.0;
155 let y_axis_height = 400.0;
156 let y_axis_num_intervals = 20;
157 let y_axis_interval = (10.0_f64).powf(((y_axis_range.1 - y_axis_range.0).log10()).ceil())
158 / (y_axis_num_intervals as f64);
159
160 y_axis_range = (
161 f64::floor(y_axis_range.0 / y_axis_interval) * y_axis_interval,
162 f64::ceil(y_axis_range.1 / y_axis_interval) * y_axis_interval,
163 );
164
165 let gutter = Gutter {
166 top: 40.0,
167 bottom: 80.0,
168 left: 80.0,
169 right: 80.0,
170 };
171
172 Ok(RenderData {
173 title: cd.title.to_owned(),
174 units: cd.units.to_owned(),
175 plot_width,
176 y_axis_height,
177 y_axis_range,
178 y_axis_interval,
179 gutter,
180 styles: vec![
181 ".line{fill:none;stroke:rgb(0,0,200);stroke-width:2;}".to_owned(),
182 ".axis{fill:none;stroke:rgb(0,0,0);stroke-width:1;}".to_owned(),
183 ".labels{fill:rgb(0,0,0);font-size:10;font-family:Arial}".to_owned(),
184 ".y-labels{text-anchor:end;}".to_owned(),
185 ".title{font-family:Arial;font-size:12;text-anchor:middle;}".to_owned(),
186 ],
187 tuples,
188 })
189 }
190
191 fn render_chart(self: &Self, rd: &RenderData) -> Result<Document, Box<dyn Error>> {
192 let width = rd.gutter.left + ((rd.tuples.len() as f64) * rd.plot_width) + rd.gutter.right;
193 let height = rd.gutter.top + rd.gutter.bottom + rd.y_axis_height;
194 let y_range = ((rd.y_axis_range.1 - rd.y_axis_range.0) / rd.y_axis_interval) as usize;
195 let y_scale = rd.y_axis_height / (rd.y_axis_range.1 - rd.y_axis_range.0);
196 let scale =
197 |n: &f64| -> f64 { height - rd.gutter.bottom - (n - rd.y_axis_range.0) * y_scale };
198 let mut document = Document::new()
199 .set("xmlns", "http://www.w3.org/2000/svg")
200 .set("width", width)
201 .set("height", height)
202 .set("viewBox", format!("0 0 {} {}", width, height))
203 .set("style", "background-color: white;");
204 let style = element::Style::new(rd.styles.join("\n"));
205 let axis = element::Polyline::new().set("class", "axis").set(
206 "points",
207 vec![
208 (rd.gutter.left, rd.gutter.top),
209 (rd.gutter.left, rd.gutter.top + rd.y_axis_height),
210 (width - rd.gutter.right, rd.gutter.top + rd.y_axis_height),
211 ],
212 );
213 let mut x_axis_labels = element::Group::new().set("class", "labels");
214
215 for i in 0..rd.tuples.len() {
216 x_axis_labels.append(element::Text::new(format!("{}", rd.tuples[i].0)).set(
217 "transform",
218 format!(
219 "translate({},{}) rotate(45)",
220 rd.gutter.left + (i as f64 * rd.plot_width) + rd.plot_width / 2.0,
221 height - rd.gutter.bottom + 15.0
222 ),
223 ));
224 }
225
226 let mut y_axis_labels = element::Group::new().set("class", "labels y-labels");
227
228 for i in 0..=y_range {
229 let n = i as f64 * rd.y_axis_interval;
230
231 y_axis_labels.append(
232 element::Text::new(format!("{}", n + rd.y_axis_range.0)).set(
233 "transform",
234 format!(
235 "translate({},{})",
236 rd.gutter.left - 10.0,
237 height - rd.gutter.bottom - f64::floor(n * y_scale) + 5.0
238 ),
239 ),
240 );
241 }
242
243 let line = element::Path::new().set("class", "line").set(
244 "d",
245 path::Data::from(
246 rd.tuples
247 .iter()
248 .enumerate()
249 .map(|t| {
250 let x = rd.gutter.left + (t.0 as f64) * rd.plot_width + rd.plot_width / 2.0;
251 let y = scale(&(*t.1).1);
252
253 if t.0 == 0 {
254 path::Command::Move(
255 path::Position::Absolute,
256 path::Parameters::from((x, y)),
257 )
258 } else {
259 path::Command::Line(
260 path::Position::Absolute,
261 path::Parameters::from((x, y)),
262 )
263 }
264 })
265 .collect::<Vec<_>>(),
266 ),
267 );
268
269 let title = element::Text::new(format!("{} ({})", &rd.title, &rd.units))
270 .set("class", "title")
271 .set("x", width / 2.0)
272 .set("y", rd.gutter.top / 2.0);
273
274 document.append(style);
275 document.append(axis);
276 document.append(x_axis_labels);
277 document.append(y_axis_labels);
278 document.append(line);
279 document.append(title);
280
281 Ok(document)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn basic_test() {
291 struct TestLogger;
292
293 impl TestLogger {
294 fn new() -> TestLogger {
295 TestLogger {}
296 }
297 }
298
299 impl LineChartLog for TestLogger {
300 fn output(self: &Self, _args: Arguments) {}
301 fn warning(self: &Self, _args: Arguments) {}
302 fn error(self: &Self, _args: Arguments) {}
303 }
304
305 let logger = TestLogger::new();
306 let mut tool = LineChartTool::new(&logger);
307 let args: Vec<std::ffi::OsString> = vec!["".into(), "--help".into()];
308
309 tool.run(args).unwrap();
310 }
311}