Skip to main content

pito_compare/
chart.rs

1use std::path::Path;
2
3use serde::Deserialize;
4
5use crate::canvas::{self, Canvas, Color};
6use crate::{Error, Result};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum Better {
10    Higher,
11    Lower,
12}
13
14#[derive(Clone, Debug, PartialEq)]
15pub struct Words {
16    pub better: String,
17    pub worse: String,
18    pub same: String,
19}
20
21impl Default for Words {
22    fn default() -> Self {
23        Self {
24            better: "FASTER".into(),
25            worse: "SLOWER".into(),
26            same: "SAME SPEED".into(),
27        }
28    }
29}
30
31#[derive(Clone, Debug, PartialEq)]
32pub struct Party {
33    pub name: String,
34    pub colour: Color,
35}
36
37#[derive(Clone, Debug, Default, PartialEq)]
38pub struct Row {
39    pub label: String,
40    pub mark: String,
41    pub values: Vec<f64>,
42    pub texts: Vec<String>,
43    pub ratio: Option<f64>,
44}
45
46#[derive(Clone, Debug, Default, PartialEq)]
47pub struct Group {
48    pub name: String,
49    pub rows: Vec<Row>,
50}
51
52#[derive(Clone, Debug, PartialEq)]
53pub struct Chart {
54    pub title: String,
55    pub unit: String,
56    pub better: Better,
57    pub subtitle: String,
58    pub date: String,
59    pub parties: Vec<Party>,
60    pub versus: usize,
61    pub groups: Vec<Group>,
62    pub notes: Vec<String>,
63    pub suffix: String,
64    pub decimals: Option<usize>,
65    pub words: Words,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum Format {
70    Json,
71    Toml,
72}
73
74pub const ROLES: &[(&str, Color)] = &[
75    ("ours", canvas::OURS),
76    ("theirs", canvas::THEIRS),
77    ("dim", canvas::DIM),
78    ("text", canvas::TEXT),
79    ("faster", canvas::FASTER),
80    ("slower", canvas::SLOWER),
81];
82
83const DEFAULT_ROLES: [&str; 4] = ["ours", "theirs", "dim", "text"];
84
85impl Row {
86    pub fn new(label: &str, values: &[f64]) -> Self {
87        Self {
88            label: label.into(),
89            values: values.to_vec(),
90            ..Self::default()
91        }
92    }
93
94    pub fn shown_label(&self) -> String {
95        if self.mark.is_empty() {
96            self.label.clone()
97        } else {
98            format!("{} {}", self.label, self.mark)
99        }
100    }
101}
102
103impl Chart {
104    pub fn new(title: &str, unit: &str, better: Better, parties: &[&str]) -> Self {
105        Self {
106            title: title.into(),
107            unit: unit.into(),
108            better,
109            subtitle: String::new(),
110            date: String::new(),
111            parties: parties
112                .iter()
113                .enumerate()
114                .map(|(i, name)| Party {
115                    name: (*name).into(),
116                    colour: role(DEFAULT_ROLES[i % DEFAULT_ROLES.len()]).unwrap_or(canvas::TEXT),
117                })
118                .collect(),
119            versus: 1,
120            groups: Vec::new(),
121            notes: Vec::new(),
122            suffix: String::new(),
123            decimals: None,
124            words: Words::default(),
125        }
126    }
127
128    pub fn load(path: &Path) -> Result<Self> {
129        let text = std::fs::read_to_string(path)
130            .map_err(|e| Error::new(format!("cannot read {}: {e}", path.display())))?;
131        let format = match path.extension().and_then(|e| e.to_str()) {
132            Some(e) if e.eq_ignore_ascii_case("json") => Format::Json,
133            Some(e) if e.eq_ignore_ascii_case("toml") => Format::Toml,
134            _ if text.trim_start().starts_with('{') => Format::Json,
135            _ => Format::Toml,
136        };
137        Self::parse(&text, format).map_err(|e| Error::new(format!("{}: {e}", path.display())))
138    }
139
140    pub fn parse(text: &str, format: Format) -> Result<Self> {
141        let spec: Spec = match format {
142            Format::Json => serde_json::from_str(text).map_err(|e| Error::new(e.to_string()))?,
143            Format::Toml => toml::from_str(text).map_err(|e| toml_error(text, &e))?,
144        };
145        let chart = spec.into_chart()?;
146        chart.check()?;
147        Ok(chart)
148    }
149
150    pub fn rows(&self) -> impl Iterator<Item = &Row> {
151        self.groups.iter().flat_map(|g| g.rows.iter())
152    }
153
154    pub fn ratio(&self, row: &Row) -> Option<f64> {
155        if let Some(r) = row.ratio {
156            return Some(r);
157        }
158        let ours = *row.values.first()?;
159        let theirs = *row.values.get(self.versus)?;
160        if ours <= 0.0 || theirs <= 0.0 {
161            return None;
162        }
163        Some(match self.better {
164            Better::Higher => ours / theirs,
165            Better::Lower => theirs / ours,
166        })
167    }
168
169    pub fn value_text(&self, row: &Row, party: usize) -> String {
170        if let Some(t) = row.texts.get(party) {
171            return t.clone();
172        }
173        let v = row.values.get(party).copied().unwrap_or(0.0);
174        let n = match self.decimals {
175            Some(d) => format!("{v:.d$}"),
176            None if v < 100.0 => format!("{v:.1}"),
177            None => format!("{v:.0}"),
178        };
179        format!("{n}{}", self.suffix)
180    }
181
182    pub fn unit_line(&self) -> String {
183        let better = match self.better {
184            Better::Higher => "HIGHER IS BETTER",
185            Better::Lower => "LOWER IS BETTER",
186        };
187        if self.unit.is_empty() {
188            better.into()
189        } else {
190            format!("{}, {better}", self.unit)
191        }
192    }
193
194    pub fn subtitle_line(&self) -> String {
195        [self.subtitle.as_str(), self.date.as_str()]
196            .into_iter()
197            .filter(|s| !s.is_empty())
198            .collect::<Vec<_>>()
199            .join(", ")
200    }
201
202    pub fn check(&self) -> Result<()> {
203        fits("title", &self.title, 4)?;
204        if self.title.trim().is_empty() {
205            return Err(Error::new("title is empty"));
206        }
207        fits("unit", &self.unit_line(), 3)?;
208        fits("subtitle and date", &self.subtitle_line(), 2)?;
209        if self.parties.is_empty() {
210            return Err(Error::new("parties is empty: name one party or more"));
211        }
212        for (i, p) in self.parties.iter().enumerate() {
213            let at = format!("parties[{i}].name");
214            if p.name.trim().is_empty() {
215                return Err(Error::new(format!("{at} is empty")));
216            }
217            glyphs(&at, &p.name)?;
218            if Canvas::text_width(&p.name, 3) > crate::draw::WIDTH - 2 * crate::draw::LEFT - 42 {
219                return Err(Error::new(format!("{at} is too long for the legend")));
220            }
221        }
222        if self.parties.len() > 1 && (self.versus == 0 || self.versus >= self.parties.len()) {
223            return Err(Error::new(
224                "versus must name a party other than the first, which is the one compared",
225            ));
226        }
227        for (k, w) in [
228            ("words.better", &self.words.better),
229            ("words.worse", &self.words.worse),
230            ("words.same", &self.words.same),
231        ] {
232            if w.trim().is_empty() {
233                return Err(Error::new(format!("{k} is empty")));
234            }
235            glyphs(k, w)?;
236        }
237        if self.rows().next().is_none() {
238            return Err(Error::new("there are no rows to draw"));
239        }
240        for (gi, g) in self.groups.iter().enumerate() {
241            fits(&format!("groups[{gi}].name"), &g.name, 2)?;
242            if g.rows.is_empty() {
243                return Err(Error::new(format!("groups[{gi}] has no rows")));
244            }
245            for (ri, row) in g.rows.iter().enumerate() {
246                let at = format!("groups[{gi}].rows[{ri}]");
247                fits(&format!("{at}.label"), &row.shown_label(), 3)?;
248                if !row.values.is_empty() && row.values.len() != self.parties.len() {
249                    return Err(Error::new(format!(
250                        "{at}.values has {} values for {} parties",
251                        row.values.len(),
252                        self.parties.len()
253                    )));
254                }
255                if let Some(v) = row.values.iter().find(|v| !v.is_finite() || **v < 0.0) {
256                    return Err(Error::new(format!(
257                        "{at}.values holds {v}: values are finite and zero or more"
258                    )));
259                }
260                if !row.texts.is_empty() && row.texts.len() != self.parties.len() {
261                    return Err(Error::new(format!(
262                        "{at}.texts has {} texts for {} parties",
263                        row.texts.len(),
264                        self.parties.len()
265                    )));
266                }
267                for t in &row.texts {
268                    glyphs(&format!("{at}.texts"), t)?;
269                }
270                if let Some(r) = row.ratio
271                    && !(r.is_finite() && r > 0.0)
272                {
273                    return Err(Error::new(format!(
274                        "{at}.ratio is {r}: a ratio is finite and above zero"
275                    )));
276                }
277                if row.values.is_empty() && row.ratio.is_none() {
278                    return Err(Error::new(format!("{at} has neither values nor a ratio")));
279                }
280            }
281        }
282        glyphs("suffix", &self.suffix)?;
283        for (i, n) in self.notes.iter().enumerate() {
284            let at = format!("notes[{i}]");
285            glyphs(&at, n)?;
286            let max = ((crate::draw::WIDTH - 2 * crate::draw::LEFT) / 18) as usize;
287            if let Some(w) = n.split_whitespace().find(|w| w.chars().count() > max) {
288                return Err(Error::new(format!(
289                    "{at} has a word longer than a line ({max} characters): {w}"
290                )));
291            }
292        }
293        Ok(())
294    }
295}
296
297fn glyphs(at: &str, text: &str) -> Result<()> {
298    match text.chars().find(|c| !canvas::has_glyph(*c)) {
299        Some(c) => Err(Error::new(format!(
300            "{at}: the chart font has no {c:?}; it draws A to Z, 0 to 9, space and . , : ; - + / ( ) % = < > ' _ | × * †"
301        ))),
302        None => Ok(()),
303    }
304}
305
306fn fits(at: &str, text: &str, scale: usize) -> Result<()> {
307    glyphs(at, text)?;
308    let room = crate::draw::WIDTH - 2 * crate::draw::LEFT;
309    if Canvas::text_width(text, scale) > room {
310        let max = (room + scale as i64) / (6 * scale as i64);
311        return Err(Error::new(format!(
312            "{at} is too long: at most {max} characters fit"
313        )));
314    }
315    Ok(())
316}
317
318pub fn role(name: &str) -> Option<Color> {
319    ROLES
320        .iter()
321        .find(|(r, _)| r.eq_ignore_ascii_case(name))
322        .map(|(_, c)| *c)
323}
324
325pub fn colour(text: &str) -> Option<Color> {
326    if let Some(c) = role(text) {
327        return Some(c);
328    }
329    let hex = text.strip_prefix('#')?;
330    if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
331        return None;
332    }
333    let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
334    Some([byte(0)?, byte(2)?, byte(4)?])
335}
336
337fn toml_error(text: &str, e: &toml::de::Error) -> Error {
338    let message = e.message().trim().to_string();
339    match e.span() {
340        Some(span) => {
341            let before = text.get(..span.start).unwrap_or(text);
342            let line = before.matches('\n').count() + 1;
343            let column = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1;
344            Error::new(format!("{message} at line {line} column {column}"))
345        }
346        None => Error::new(message),
347    }
348}
349
350#[derive(Deserialize)]
351#[serde(deny_unknown_fields)]
352struct Spec {
353    title: String,
354    #[serde(default)]
355    unit: String,
356    better: BetterSpec,
357    #[serde(default)]
358    subtitle: String,
359    #[serde(default)]
360    date: String,
361    parties: Vec<PartySpec>,
362    #[serde(default)]
363    versus: Option<String>,
364    #[serde(default)]
365    groups: Vec<GroupSpec>,
366    #[serde(default)]
367    rows: Vec<RowSpec>,
368    #[serde(default)]
369    notes: Vec<String>,
370    #[serde(default)]
371    suffix: String,
372    #[serde(default)]
373    decimals: Option<usize>,
374    #[serde(default)]
375    words: WordsSpec,
376}
377
378#[derive(Deserialize)]
379#[serde(rename_all = "lowercase")]
380enum BetterSpec {
381    Higher,
382    Lower,
383}
384
385#[derive(Deserialize)]
386#[serde(deny_unknown_fields)]
387struct PartySpec {
388    name: String,
389    #[serde(default, alias = "color")]
390    colour: Option<String>,
391}
392
393#[derive(Deserialize)]
394#[serde(deny_unknown_fields)]
395struct GroupSpec {
396    #[serde(default)]
397    name: String,
398    rows: Vec<RowSpec>,
399}
400
401#[derive(Deserialize)]
402#[serde(deny_unknown_fields)]
403struct RowSpec {
404    label: String,
405    #[serde(default)]
406    mark: String,
407    #[serde(default)]
408    values: Vec<f64>,
409    #[serde(default)]
410    texts: Vec<String>,
411    #[serde(default)]
412    ratio: Option<f64>,
413}
414
415#[derive(Deserialize, Default)]
416#[serde(deny_unknown_fields)]
417struct WordsSpec {
418    better: Option<String>,
419    worse: Option<String>,
420    same: Option<String>,
421}
422
423impl RowSpec {
424    fn into_row(self) -> Row {
425        Row {
426            label: self.label,
427            mark: self.mark,
428            values: self.values,
429            texts: self.texts,
430            ratio: self.ratio,
431        }
432    }
433}
434
435impl Spec {
436    fn into_chart(self) -> Result<Chart> {
437        if !self.groups.is_empty() && !self.rows.is_empty() {
438            return Err(Error::new("give rows or groups, not both"));
439        }
440        if self.decimals.is_some_and(|d| d > 6) {
441            return Err(Error::new("decimals is at most 6"));
442        }
443        let mut parties = Vec::new();
444        for (i, p) in self.parties.into_iter().enumerate() {
445            let colour = match &p.colour {
446                Some(c) => colour(c).ok_or_else(|| {
447                    Error::new(format!(
448                        "parties[{i}].colour is {c:?}: use #RRGGBB or a role (ours, theirs, dim, text, faster, slower)"
449                    ))
450                })?,
451                None => role(DEFAULT_ROLES[i % DEFAULT_ROLES.len()]).unwrap_or(canvas::TEXT),
452            };
453            parties.push(Party {
454                name: p.name,
455                colour,
456            });
457        }
458        let versus = match &self.versus {
459            None => 1,
460            Some(name) => parties
461                .iter()
462                .position(|p| p.name.eq_ignore_ascii_case(name))
463                .ok_or_else(|| Error::new(format!("versus names {name:?}, which is no party")))?,
464        };
465        let groups = if self.rows.is_empty() {
466            self.groups
467                .into_iter()
468                .map(|g| Group {
469                    name: g.name,
470                    rows: g.rows.into_iter().map(RowSpec::into_row).collect(),
471                })
472                .collect()
473        } else {
474            vec![Group {
475                name: String::new(),
476                rows: self.rows.into_iter().map(RowSpec::into_row).collect(),
477            }]
478        };
479        let defaults = Words::default();
480        Ok(Chart {
481            title: self.title,
482            unit: self.unit,
483            better: match self.better {
484                BetterSpec::Higher => Better::Higher,
485                BetterSpec::Lower => Better::Lower,
486            },
487            subtitle: self.subtitle,
488            date: self.date,
489            parties,
490            versus,
491            groups,
492            notes: self.notes,
493            suffix: self.suffix,
494            decimals: self.decimals,
495            words: Words {
496                better: self.words.better.unwrap_or(defaults.better),
497                worse: self.words.worse.unwrap_or(defaults.worse),
498                same: self.words.same.unwrap_or(defaults.same),
499            },
500        })
501    }
502}