line_chart/
lib.rs

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    /// The JSON5 input file
29    #[clap(value_name = "INPUT_FILE")]
30    input_file: Option<PathBuf>,
31
32    /// The SVG output file
33    #[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}