A flow drawn as one SVG from data: a spine of steps down the left, and from any step the ways it can end on the right. The picture is built from the rows it is given and nothing else, so a site that builds the rows from the code that runs the flow cannot draw a step the code lacks.
Text is real SVG text, wrapped here to fit its box (the face is monospace, so a width is a count of characters), so it can be selected, translated and read by a screen reader. On a narrow screen the drawing keeps its size and scrolls sideways, as lmjtfy's rules diagram does.
9use std::fmt::Write;
11use maud::{Markup, PreEscaped, html};
Paper, ink outline.
17 Plain,
Pink: this is where Jev is asked something.
19 Jev,
Why, as the first line.
28 pub cause: String,
What is said or happens next.
30 pub then: String,
A stable name, written as data-id so a test can find the row.
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}
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}
The drawing, as an <svg> inside a scrolling div. title and summary are its accessible
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}
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}