Skip to main content

pito_compare/
draw.rs

1use crate::canvas::{Canvas, Color, DIM, FASTER, GRID, SEP, SLOWER, TEXT};
2use crate::chart::{Chart, Words};
3use crate::{Error, Result};
4
5pub const WIDTH: i64 = 1080;
6pub const LEFT: i64 = 40;
7pub const ROW: i64 = 124;
8pub const RATIO_ROW: i64 = 66;
9pub const GROUP: i64 = 50;
10pub const NOTE: i64 = 30;
11pub const BAR_STEP: i64 = 32;
12pub const MAX_HEIGHT: i64 = 32_000;
13
14fn wrap(text: &str, scale: usize) -> Vec<String> {
15    let max = ((WIDTH - 2 * LEFT) / (6 * scale as i64)) as usize;
16    let mut lines = Vec::new();
17    let mut line = String::new();
18    for word in text.split_whitespace() {
19        if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > max {
20            lines.push(std::mem::take(&mut line));
21        }
22        if !line.is_empty() {
23            line.push(' ');
24        }
25        line.push_str(word);
26    }
27    if !line.is_empty() {
28        lines.push(line);
29    }
30    lines
31}
32
33fn note_lines(notes: &[String]) -> Vec<String> {
34    notes.iter().flat_map(|n| wrap(n, 3)).collect()
35}
36
37pub fn number(n: f64) -> String {
38    if n >= 10.0 {
39        format!("{n:.0}")
40    } else {
41        format!("{n:.1}")
42    }
43}
44
45pub fn times(r: f64, words: &Words) -> String {
46    let (n, word) = if r >= 1.0 {
47        (r, &words.better)
48    } else {
49        (1.0 / r, &words.worse)
50    };
51    let n = number(n);
52    if n == "1.0" {
53        words.same.clone()
54    } else {
55        format!("{n}× {word}")
56    }
57}
58
59fn tone(r: f64) -> Color {
60    if number(r.max(1.0 / r)) == "1.0" {
61        DIM
62    } else if r >= 1.0 {
63        FASTER
64    } else {
65        SLOWER
66    }
67}
68
69fn nice_step(max: f64) -> f64 {
70    let raw = max / 5.0;
71    let mag = 10f64.powf(raw.log10().floor());
72    [1.0, 2.0, 2.5, 5.0, 10.0]
73        .into_iter()
74        .map(|m| m * mag)
75        .find(|s| raw <= *s)
76        .unwrap_or(10.0 * mag)
77}
78
79fn tick_text(v: f64) -> String {
80    if v.fract() == 0.0 {
81        format!("{v:.0}")
82    } else {
83        let s = format!("{v:.6}");
84        s.trim_end_matches('0').trim_end_matches('.').to_string()
85    }
86}
87
88fn header(c: &mut Canvas, title: &str, unit: &str, subtitle: &str) {
89    c.text(LEFT, 32, title, 4, TEXT);
90    c.text(LEFT, 80, unit, 3, TEXT);
91    c.text(LEFT, 118, subtitle, 2, DIM);
92}
93
94fn group_row(c: &mut Canvas, y: i64, name: &str, first: bool) {
95    if !first {
96        c.rect(LEFT, y + 6, WIDTH - 2 * LEFT, 2, SEP);
97    }
98    c.text(LEFT, y + 22, name, 2, DIM);
99}
100
101type Slots = Vec<(i64, Option<(i64, String)>)>;
102
103fn layout(chart: &Chart, top: i64, row: i64) -> (Slots, i64) {
104    let mut at = Vec::new();
105    let mut y = top;
106    for g in &chart.groups {
107        let mut head = if g.name.is_empty() {
108            None
109        } else {
110            let h = Some((y, g.name.clone()));
111            y += GROUP;
112            h
113        };
114        for _ in &g.rows {
115            at.push((y, head.take()));
116            y += row;
117        }
118    }
119    (at, y)
120}
121
122fn draw_groups(c: &mut Canvas, at: &Slots) {
123    for (i, (gy, name)) in at.iter().filter_map(|(_, h)| h.as_ref()).enumerate() {
124        group_row(c, *gy, name, i == 0);
125    }
126}
127
128fn canvas(height: i64) -> Result<Canvas> {
129    if height > MAX_HEIGHT {
130        return Err(Error::new(format!(
131            "the chart would be {height} px tall, over {MAX_HEIGHT}: draw fewer rows"
132        )));
133    }
134    Ok(Canvas::new(WIDTH as usize, height as usize))
135}
136
137fn legend(c: Option<&mut Canvas>, items: &[(&str, Color)]) -> i64 {
138    let mut c = c;
139    let (mut x, mut y) = (LEFT, 154);
140    for (name, colour) in items {
141        let w = 42 + Canvas::text_width(name, 3);
142        if x > LEFT && x + w > WIDTH - LEFT {
143            x = LEFT;
144            y += 34;
145        }
146        if let Some(c) = c.as_deref_mut() {
147            c.rect(x, y, 30, 21, *colour);
148            c.text(x + 42, y, name, 3, TEXT);
149        }
150        x += w + 50;
151    }
152    y - 154
153}
154
155fn bar_chart(chart: &Chart, parties: &[usize], tags: bool) -> Result<Canvas> {
156    chart.check()?;
157    if let Some((i, _)) = chart
158        .rows()
159        .enumerate()
160        .find(|(_, r)| r.values.len() != chart.parties.len())
161    {
162        return Err(Error::new(format!(
163            "row {} ({}) has no values to draw as bars",
164            i + 1,
165            chart.rows().nth(i).map_or("", |r| r.label.as_str())
166        )));
167    }
168    let n = parties.len() as i64;
169    let items: Vec<(&str, Color)> = parties
170        .iter()
171        .map(|&p| (chart.parties[p].name.as_str(), chart.parties[p].colour))
172        .collect();
173    let extra = legend(None, &items);
174    let top = 250 + extra;
175    let row_height = ROW - 2 * BAR_STEP + n * BAR_STEP;
176    let (at, bottom) = layout(chart, top, row_height);
177    let notes = note_lines(&chart.notes);
178    let height = bottom + 40 + notes.len() as i64 * NOTE + 30;
179    let mut c = canvas(height)?;
180    header(
181        &mut c,
182        &chart.title,
183        &chart.unit_line(),
184        &chart.subtitle_line(),
185    );
186    legend(Some(&mut c), &items);
187    let max = chart
188        .rows()
189        .flat_map(|r| parties.iter().map(|&p| r.values[p]))
190        .fold(0.0, f64::max);
191    let max = if max > 0.0 { max } else { 1.0 };
192    let step = nice_step(max);
193    let peak = (max / step).ceil() * step;
194    let mut aw = WIDTH - 2 * LEFT - 230;
195    for r in chart.rows() {
196        for &p in parties {
197            let v = r.values[p];
198            if v <= 0.0 {
199                continue;
200            }
201            let tw = Canvas::text_width(&chart.value_text(r, p), 3);
202            let room = WIDTH - 2 * LEFT - 12 - tw;
203            let fit = (room as f64 * peak / v).floor() as i64;
204            aw = aw.min(fit);
205        }
206    }
207    if aw < 300 {
208        return Err(Error::new(
209            "the value texts are too long to fit beside the bars: shorten the suffix or the texts",
210        ));
211    }
212    let x = |v: f64| LEFT + (v / peak * aw as f64).round() as i64;
213    let mut grid = Vec::new();
214    let mut t = 0.0;
215    while t <= peak + step / 2.0 {
216        let gx = x(t);
217        grid.push((gx, t == 0.0));
218        let label = tick_text(t);
219        let w = Canvas::text_width(&label, 2);
220        c.text(
221            gx - if t == 0.0 { 0 } else { w / 2 },
222            top - 40,
223            &label,
224            2,
225            DIM,
226        );
227        t += step;
228    }
229    draw_groups(&mut c, &at);
230    for (r, (y, _)) in chart.rows().zip(&at) {
231        let y = *y;
232        for &(gx, zero) in &grid {
233            c.rect(
234                gx,
235                y + 28,
236                if zero { 2 } else { 1 },
237                BAR_STEP * n,
238                if zero { DIM } else { GRID },
239            );
240        }
241        let label = r.shown_label();
242        c.text(LEFT, y, &label, 3, TEXT);
243        if tags && let Some(ratio) = chart.ratio(r) {
244            let tag = times(ratio, &chart.words);
245            let tw = Canvas::text_width(&tag, 3);
246            let lw = Canvas::text_width(&label, 3);
247            let ty = if LEFT + lw + 24 + tw > WIDTH - LEFT {
248                y + 34
249            } else {
250                y
251            };
252            c.text(WIDTH - LEFT - tw, ty, &tag, 3, tone(ratio));
253        }
254        for (i, &p) in parties.iter().enumerate() {
255            let by = y + 32 + i as i64 * BAR_STEP;
256            let len = (x(r.values[p]) - LEFT).max(2);
257            c.rect(LEFT, by, len, 26, chart.parties[p].colour);
258            c.text(LEFT + len + 12, by + 3, &chart.value_text(r, p), 3, TEXT);
259        }
260    }
261    for (i, line) in notes.iter().enumerate() {
262        c.text(LEFT, bottom + 30 + i as i64 * NOTE, line, 3, DIM);
263    }
264    Ok(c)
265}
266
267pub fn paired(chart: &Chart) -> Result<Canvas> {
268    if chart.parties.len() < 2 {
269        return Err(Error::new(
270            "the paired chart needs two parties or more; a single party draws as bars",
271        ));
272    }
273    let all: Vec<usize> = (0..chart.parties.len()).collect();
274    bar_chart(chart, &all, true)
275}
276
277pub fn bars(chart: &Chart, party: usize) -> Result<Canvas> {
278    if party >= chart.parties.len() {
279        return Err(Error::new(format!(
280            "there is no party {party}; the chart has {}",
281            chart.parties.len()
282        )));
283    }
284    bar_chart(chart, &[party], false)
285}
286
287pub fn ratios(chart: &Chart) -> Result<Canvas> {
288    chart.check()?;
289    if chart.parties.len() < 2 && chart.rows().any(|r| r.ratio.is_none()) {
290        return Err(Error::new(
291            "the ratio chart needs two parties, or a ratio on every row",
292        ));
293    }
294    let mut speedups = Vec::new();
295    for (i, r) in chart.rows().enumerate() {
296        match chart.ratio(r) {
297            Some(s) => speedups.push(s),
298            None => {
299                return Err(Error::new(format!(
300                    "row {} ({}) has no ratio: the compared values must be above zero",
301                    i + 1,
302                    r.label
303                )));
304            }
305        }
306    }
307    let versus = chart
308        .parties
309        .get(chart.versus)
310        .map_or("THE OTHER", |p| p.name.as_str());
311    let words = &chart.words;
312    let worse_caption = format!("< {} THAN {versus}", words.worse);
313    let better_caption = format!("{} THAN {versus} >", words.better);
314    let half_room = (WIDTH - 2 * LEFT) / 2 - 20;
315    if Canvas::text_width(&worse_caption, 2) > half_room
316        || Canvas::text_width(&better_caption, 2) > half_room
317    {
318        return Err(Error::new(format!(
319            "the ratio chart's captions are too long: \"{better_caption}\" must fit half the width"
320        )));
321    }
322    let unit = format!("TIMES {} OR {}, LOG SCALE", words.better, words.worse);
323    if Canvas::text_width(&unit, 3) > WIDTH - 2 * LEFT {
324        return Err(Error::new(format!(
325            "the ratio chart's unit line is too long: {unit}"
326        )));
327    }
328    let top = 270i64;
329    let (at, bottom) = layout(chart, top, RATIO_ROW);
330    let notes = note_lines(&chart.notes);
331    let height = bottom + 40 + notes.len() as i64 * NOTE + 30;
332    let mut c = canvas(height)?;
333    header(&mut c, &chart.title, &unit, &chart.subtitle_line());
334    let worst = speedups.iter().map(|s| s.max(1.0 / s)).fold(1.0, f64::max);
335    let decades = [2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0];
336    let bound = decades.into_iter().find(|d| *d >= worst).unwrap_or(1000.0);
337    let cx = WIDTH / 2;
338    let half = cx - LEFT - 90;
339    let x =
340        |r: f64| cx + (r.clamp(1.0 / bound, bound).ln() / bound.ln() * half as f64).round() as i64;
341    c.text(LEFT, 160, &worse_caption, 2, DIM);
342    let fw = Canvas::text_width(&better_caption, 2);
343    c.text(WIDTH - LEFT - fw, 160, &better_caption, 2, DIM);
344    let mut ticks = vec![1.0];
345    ticks.extend(decades.into_iter().filter(|d| *d <= bound));
346    let mut grid = Vec::new();
347    let mut centre: Option<(i64, i64)> = None;
348    for side in [1.0f64, -1.0] {
349        let mut edge = centre.map(|(l, r)| if side > 0.0 { r } else { l });
350        for &t in &ticks {
351            if t == 1.0 && side < 0.0 {
352                continue;
353            }
354            let r = t.powf(side);
355            let gx = x(r);
356            grid.push((gx, t == 1.0));
357            if !(t <= 2.0 || t == bound || t.log10().fract() == 0.0) {
358                continue;
359            }
360            let (a, b) = if t == 1.0 {
361                ("1×".to_string(), "SAME")
362            } else {
363                (
364                    format!("{}×", tick_text(t)),
365                    if side > 0.0 {
366                        words.better.as_str()
367                    } else {
368                        words.worse.as_str()
369                    },
370                )
371            };
372            let w = Canvas::text_width(&a, 2).max(Canvas::text_width(b, 2));
373            let (l, r_) = (gx - w / 2, gx + w / 2);
374            let fits = match edge {
375                None => true,
376                Some(e) if side > 0.0 => l > e + 14,
377                Some(e) => r_ < e - 14,
378            };
379            if !fits || l < LEFT || r_ > WIDTH - LEFT {
380                continue;
381            }
382            if t == 1.0 {
383                centre = Some((l, r_));
384            }
385            edge = Some(if side > 0.0 { r_ } else { l });
386            c.text(gx - Canvas::text_width(&a, 2) / 2, top - 62, &a, 2, DIM);
387            c.text(gx - Canvas::text_width(b, 2) / 2, top - 42, b, 2, DIM);
388        }
389    }
390    draw_groups(&mut c, &at);
391    for ((r, speedup), (y, _)) in chart.rows().zip(&speedups).zip(&at) {
392        let (y, speedup) = (*y, *speedup);
393        for &(gx, one) in &grid {
394            c.rect(
395                gx,
396                y + 24,
397                if one { 2 } else { 1 },
398                28,
399                if one { DIM } else { GRID },
400            );
401        }
402        c.text(LEFT, y, &r.shown_label(), 3, TEXT);
403        let (x0, x1) = (x(1.0), x(speedup));
404        let colour = if speedup >= 1.0 { FASTER } else { SLOWER };
405        c.rect(x0.min(x1), y + 27, (x1 - x0).abs().max(2), 22, colour);
406        let tag = format!("{}×", number(speedup.max(1.0 / speedup)));
407        let tw = Canvas::text_width(&tag, 3);
408        let tx = if speedup >= 1.0 {
409            x1.max(x0) + 10
410        } else {
411            x1.min(x0) - 10 - tw
412        };
413        c.text(tx, y + 28, &tag, 3, TEXT);
414    }
415    for (i, line) in notes.iter().enumerate() {
416        c.text(LEFT, bottom + 30 + i as i64 * NOTE, line, 3, DIM);
417    }
418    Ok(c)
419}