jevcrates.git / jev-ui / src / flow.rs
flow.rsannotatedflow.rssource287 lines · 10.6 KB · raw
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
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 &amp; t</title>"));
257        assert!(html.contains("Jev cannot be reached &lt;at all&gt;"));
258        assert!(html.contains("Parents see: the refusal &amp; 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}