jevcrates.git / jev-ui / src / flow.rs
flow.rsannotatedflow.rssource287 lines · 10.6 KB · raw

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};

How a step is drawn.

14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Tone {

Paper, ink outline.

17    Plain,

Pink: this is where Jev is asked something.

19    Jev,

Ink, the turn's last step.

21    End,
22}

One way a step can end.

25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Exit {

Why, as the first line.

28    pub cause: String,

What is said or happens next.

30    pub then: String,

What the parents see, if anything is to be said.

32    pub note: String,
33}
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Row {

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}
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;

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('&', "&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}

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 &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}