1//! A flow drawn as one SVG from data: a spine of steps down the left, and from any step the ways it 2//! can end on the right. The picture is built from the rows it is given and nothing else, so a site 3//! that builds the rows from the code that runs the flow cannot draw a step the code lacks. 4//! 5//! Text is real SVG text, wrapped here to fit its box (the face is monospace, so a width is a count 6//! of characters), so it can be selected, translated and read by a screen reader. On a narrow 7//! screen the drawing keeps its size and scrolls sideways, as lmjtfy's rules diagram does. 8 9use std::fmt::Write; 10 11use maud::{Markup, PreEscaped, html}; 12 13/// How a step is drawn. 14#[derive(Clone, Copy, Debug, PartialEq, Eq)] 15pub enum Tone { 16 /// Paper, ink outline. 17 Plain, 18 /// Pink: this is where Jev is asked something. 19 Jev, 20 /// Ink, the turn's last step. 21 End, 22} 23 24/// One way a step can end. 25#[derive(Clone, Debug, PartialEq, Eq)] 26pub struct Exit { 27 /// Why, as the first line. 28 pub cause: String, 29 /// What is said or happens next. 30 pub then: String, 31 /// What the parents see, if anything is to be said. 32 pub note: String, 33} 34 35#[derive(Clone, Debug, PartialEq, Eq)] 36pub struct Row { 37 /// A stable name, written as `data-id` so a test can find the row. 38 pub id: String, 39 pub title: String, 40 pub detail: String, 41 pub tone: Tone, 42 pub exits: Vec<Exit>, 43} 44 45const MAIN_X: u32 = 8; 46const MAIN_W: u32 = 300; 47const EXIT_X: u32 = 372; 48const EXIT_W: u32 = 320; 49const WIDTH: u32 = 700; 50const CHAR: u32 = 7; // a 11px monospace face, with its rounding 51const LINE: u32 = 14; 52const PAD: u32 = 8; 53const GAP: u32 = 22; 54const EXIT_GAP: u32 = 8; 55 56/// `text` broken at spaces into lines of at most `chars`; a word longer than a line is cut. 57pub fn wrap(text: &str, chars: usize) -> Vec<String> { 58 let mut lines: Vec<String> = Vec::new(); 59 for word in text.split_whitespace() { 60 let mut word = word.to_owned(); 61 while word.chars().count() > chars { 62 let head: String = word.chars().take(chars).collect(); 63 word = word.chars().skip(chars).collect(); 64 lines.push(head); 65 } 66 match lines.last_mut() { 67 Some(last) if last.chars().count() + 1 + word.chars().count() <= chars => { 68 last.push(' '); 69 last.push_str(&word); 70 } 71 _ => lines.push(word), 72 } 73 } 74 lines 75} 76 77fn fits(width: u32) -> usize { 78 ((width - 2 * PAD) / CHAR) as usize 79} 80 81fn box_height(lines: usize) -> u32 { 82 lines as u32 * LINE + 2 * PAD 83} 84 85fn esc(text: &str) -> String { 86 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) 87} 88 89struct Text { 90 lines: Vec<(String, &'static str)>, 91} 92 93impl Text { 94 fn height(&self) -> u32 { 95 box_height(self.lines.len()) 96 } 97} 98 99fn main_text(row: &Row, number: usize) -> Text { 100 let mut lines = vec![(format!("{number}. {}", row.title), "head")]; 101 lines.extend(wrap(&row.detail, fits(MAIN_W)).into_iter().map(|l| (l, ""))); 102 // The number is part of the head line, which may itself need wrapping. 103 let head = wrap(&lines[0].0, fits(MAIN_W)); 104 let mut out: Vec<(String, &'static str)> = head.into_iter().map(|l| (l, "head")).collect(); 105 out.extend(lines.into_iter().skip(1)); 106 Text { lines: out } 107} 108 109fn exit_text(exit: &Exit) -> Text { 110 let chars = fits(EXIT_W); 111 let mut lines: Vec<(String, &'static str)> = wrap(&exit.cause, chars).into_iter().map(|l| (l, "head")).collect(); 112 lines.extend(wrap(&format!("Then: {}", exit.then), chars).into_iter().map(|l| (l, ""))); 113 if !exit.note.is_empty() { 114 lines.extend(wrap(&format!("Parents see: {}", exit.note), chars).into_iter().map(|l| (l, "dim"))); 115 } 116 Text { lines } 117} 118 119fn put(out: &mut String, x: u32, y: u32, text: &Text) { 120 for (i, (line, class)) in text.lines.iter().enumerate() { 121 let class = if class.is_empty() { String::new() } else { format!(" class=\"{class}\"") }; 122 let _ = write!(out, "<text x=\"{}\" y=\"{}\"{class}>{}</text>", x + PAD, y + PAD + (i as u32 + 1) * LINE - 3, esc(line)); 123 } 124} 125 126/// The drawing, as an `<svg>` inside a scrolling `div`. `title` and `summary` are its accessible 127/// name and description. 128pub fn spine(title: &str, summary: &str, rows: &[Row]) -> Markup { 129 let mut body = String::new(); 130 let mut y = 4u32; 131 let mut centres: Vec<(u32, u32)> = Vec::new(); 132 for (i, row) in rows.iter().enumerate() { 133 let main = main_text(row, i + 1); 134 let exits: Vec<Text> = row.exits.iter().map(exit_text).collect(); 135 let stack: u32 = exits.iter().map(Text::height).sum::<u32>() + EXIT_GAP * exits.len().saturating_sub(1) as u32; 136 let height = main.height().max(stack); 137 let tone = match row.tone { 138 Tone::Plain => "plain", 139 Tone::Jev => "jev", 140 Tone::End => "end", 141 }; 142 let _ = write!( 143 body, 144 "<g class=\"step {tone}\" data-id=\"{}\"><rect x=\"{MAIN_X}\" y=\"{y}\" width=\"{MAIN_W}\" height=\"{}\"/>", 145 esc(&row.id), 146 main.height() 147 ); 148 put(&mut body, MAIN_X, y, &main); 149 body.push_str("</g>"); 150 // Each exit: a box on the right and an elbow from the step's right edge to it. 151 let mut ey = y; 152 for (exit, text) in row.exits.iter().zip(&exits) { 153 let _ = write!(body, "<g class=\"exit\" data-of=\"{}\"><rect x=\"{EXIT_X}\" y=\"{ey}\" width=\"{EXIT_W}\" height=\"{}\"/>", esc(&row.id), text.height()); 154 put(&mut body, EXIT_X, ey, text); 155 let (from_y, to_y) = (y + main.height().min(2 * LINE + PAD) / 2, ey + text.height() / 2); 156 let _ = write!( 157 body, 158 "<path d=\"M{} {from_y}H{} V{to_y}H{}\"/><polygon points=\"{},{} {},{} {},{}\"/></g>", 159 MAIN_X + MAIN_W, 160 MAIN_X + MAIN_W + (EXIT_X - MAIN_X - MAIN_W) / 2, 161 EXIT_X - 6, 162 EXIT_X, 163 to_y, 164 EXIT_X - 7, 165 to_y - 4, 166 EXIT_X - 7, 167 to_y + 4 168 ); 169 let _ = exit; 170 ey += text.height() + EXIT_GAP; 171 } 172 centres.push((y, main.height())); 173 y += height + GAP; 174 } 175 // The spine: an arrow down from each step to the next. 176 let mut arrows = String::new(); 177 for pair in centres.windows(2) { 178 let (top, h) = pair[0]; 179 let (next, _) = pair[1]; 180 let x = MAIN_X + MAIN_W / 2; 181 let _ = write!( 182 arrows, 183 "<path d=\"M{x} {}V{}\"/><polygon points=\"{x},{next} {},{} {},{}\"/>", 184 top + h, 185 next - 6, 186 x - 4, 187 next - 7, 188 x + 4, 189 next - 7 190 ); 191 } 192 let total = y.saturating_sub(GAP) + 4; 193 html! { 194 div .flow-svg { 195 svg viewBox={ "0 0 " (WIDTH) " " (total) } role="img" aria-labelledby="flow-title flow-desc" { 196 title #flow-title { (title) } 197 desc #flow-desc { (summary) } 198 g .arrows { (PreEscaped(arrows)) } 199 (PreEscaped(body)) 200 } 201 } 202 } 203} 204 205#[cfg(test)] 206mod tests { 207 use super::*; 208 209 fn rows() -> Vec<Row> { 210 vec![ 211 Row { id: "a".into(), title: "First".into(), detail: "Something is heard and kept before anything is sent anywhere at all.".into(), tone: Tone::Plain, exits: vec![] }, 212 Row { 213 id: "b".into(), 214 title: "Ask Jev".into(), 215 detail: "May this be said?".into(), 216 tone: Tone::Jev, 217 exits: vec![ 218 Exit { cause: "refused".into(), then: "a fixed line".into(), note: "the refusal & why".into() }, 219 Exit { cause: "Jev cannot be reached <at all>".into(), then: "a fixed line".into(), note: String::new() }, 220 ], 221 }, 222 Row { id: "c".into(), title: "Say".into(), detail: "Spoken.".into(), tone: Tone::End, exits: vec![] }, 223 ] 224 } 225 226 #[test] 227 fn wrap_keeps_every_word_and_every_line_within_the_width() { 228 let text = "one two three four five six seven eight nine ten eleven twelve"; 229 let lines = wrap(text, 16); 230 assert!(lines.iter().all(|l| l.chars().count() <= 16), "{lines:?}"); 231 assert_eq!(lines.join(" "), text); 232 // A word longer than a line is cut, and nothing is lost. 233 let cut = wrap("abcdefghijklmnopqrstuvwxyz", 10); 234 assert_eq!(cut.concat(), "abcdefghijklmnopqrstuvwxyz"); 235 assert!(cut.iter().all(|l| l.len() <= 10)); 236 assert!(wrap("", 10).is_empty()); 237 } 238 239 #[test] 240 fn every_row_and_every_exit_is_drawn_once_and_nothing_else_is() { 241 let html = spine("t", "d", &rows()).into_string(); 242 for id in ["a", "b", "c"] { 243 assert_eq!(html.matches(&format!("data-id=\"{id}\"")).count(), 1, "{id}"); 244 } 245 assert_eq!(html.matches("class=\"exit\"").count(), 2); 246 assert_eq!(html.matches("data-of=\"b\"").count(), 2); 247 // One box for each row and each exit. 248 assert_eq!(html.matches("<rect").count(), 5); 249 // An arrow down between each pair of steps. 250 assert_eq!(html.matches("<g class=\"arrows\">").count(), 1); 251 } 252 253 #[test] 254 fn text_is_real_text_and_escaped() { 255 let html = spine("t & t", "d", &rows()).into_string(); 256 assert!(html.contains("<title id=\"flow-title\">t & t</title>")); 257 assert!(html.contains("Jev cannot be reached <at all>")); 258 assert!(html.contains("Parents see: the refusal & why")); 259 assert!(html.contains("2. Ask Jev")); 260 assert!(!html.contains("NaN")); 261 } 262 263 #[test] 264 fn the_drawing_is_as_tall_as_its_rows_need_and_boxes_stay_inside_it() { 265 let html = spine("t", "d", &rows()).into_string(); 266 let view = html.split("viewBox=\"0 0 700 ").nth(1).unwrap().split('"').next().unwrap().parse::<u32>().unwrap(); 267 for part in html.split("<rect ").skip(1) { 268 let attr = |name: &str| -> u32 { part.split(&format!("{name}=\"")).nth(1).unwrap().split('"').next().unwrap().parse().unwrap() }; 269 assert!(attr("y") + attr("height") <= view, "a box runs off the bottom"); 270 assert!(attr("x") + attr("width") <= 700, "a box runs off the side"); 271 } 272 } 273 274 #[test] 275 fn every_line_of_text_fits_its_box() { 276 for row in rows() { 277 for (line, _) in main_text(&row, 1).lines { 278 assert!(line.chars().count() <= fits(MAIN_W), "{line}"); 279 } 280 for exit in &row.exits { 281 for (line, _) in exit_text(exit).lines { 282 assert!(line.chars().count() <= fits(EXIT_W), "{line}"); 283 } 284 } 285 } 286 } 287}