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}