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}