whiskers.git / web / cat / src / svg.rs
svg.rsannotatedsvg.rssource208 lines · 8.5 KB · raw
1//! A scene as SVG. Two modes: `Live`, for the page, where each moving group carries a class
2//! (the stylesheet in css.rs plays it) and the pupils carry whatever attributes the caller
3//! binds the pointer with; and `Frame`, for the preview, where every motion is baked in at a
4//! time and nothing needs a stylesheet.
5
6use std::fmt::Write;
7
8use crate::motion::num;
9use crate::node::{Group, Look, Node, Role};
10use crate::scene::{CENTER, HALF_WIDTH, Scene, VIEW, gaze_offset};
11
12pub enum Mode<'a> {
13    /// `eyes_attrs` is raw markup for the pupils' `<g>`, written by the caller (the Datastar
14    /// binding). It is never built from a visitor's input.
15    Live { eyes_attrs: &'a str },
16    /// `t` seconds in, with the pupils looking `gaze` (each axis -1..1).
17    Frame { t: f32, gaze: (f32, f32) },
18}
19
20/// Just the shapes, to be placed in a larger drawing.
21pub fn fragment(nodes: &[Node], mode: &Mode) -> String {
22    let mut out = String::new();
23    for node in nodes {
24        write_node(&mut out, node, mode);
25    }
26    out
27}
28
29/// A whole `<svg id="cat">`, labelled for assistive technology.
30pub fn svg(scene: &Scene, label: &str, mode: &Mode) -> String {
31    let (x, y, w, h) = VIEW;
32    format!(
33        "<svg id=\"cat\" class=\"cat\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{x} {y} {w} {h}\" role=\"img\" aria-label=\"{}\" shape-rendering=\"crispEdges\">{}</svg>",
34        escape(label),
35        fragment(&scene.nodes, mode)
36    )
37}
38
39fn write_node(out: &mut String, node: &Node, mode: &Mode) {
40    match node {
41        Node::Rect(r) => {
42            // A hair of overlap hides the seam between touching rectangles (as in the app).
43            let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"", num(r.x), num(r.y), num(r.w + 0.02), num(r.h + 0.02), r.color.css());
44            if r.alpha < 1.0 {
45                let _ = write!(out, " fill-opacity=\"{}\"", num(r.alpha));
46            }
47            out.push_str("/>");
48        }
49        Node::Group(group) => write_group(out, group, mode),
50    }
51}
52
53fn write_group(out: &mut String, group: &Group, mode: &Mode) {
54    match group.role {
55        Role::Eyes(look) => {
56            match (mode, look) {
57                (_, Look::Fixed(gx, gy)) => {
58                    let (ex, ey) = gaze_offset((gx, gy));
59                    let _ = write!(out, "<g class=\"eyes\" transform=\"translate({} {})\">", num(ex), num(ey));
60                }
61                (Mode::Live { eyes_attrs }, Look::Follow) => {
62                    let _ = write!(out, "<g class=\"eyes\" {eyes_attrs}>");
63                }
64                (Mode::Frame { gaze, .. }, Look::Follow) => {
65                    let (ex, ey) = gaze_offset(*gaze);
66                    let _ = write!(out, "<g class=\"eyes\" transform=\"translate({} {})\">", num(ex), num(ey));
67                }
68            }
69            write_children(out, group, mode);
70            out.push_str("</g>");
71        }
72        Role::Plain if group.motion == crate::motion::Motion::Still => write_children(out, group, mode),
73        Role::Plain => {
74            let (ox, oy) = group.origin;
75            match mode {
76                Mode::Live { .. } => {
77                    let rest = group.motion.rest();
78                    let _ = write!(
79                        out,
80                        "<g class=\"m-{}\" style=\"transform-origin:{}px {}px\" transform=\"{}\"",
81                        group.motion.name(),
82                        num(ox),
83                        num(oy),
84                        rest.attr(group.origin)
85                    );
86                    if rest.opacity < 1.0 {
87                        let _ = write!(out, " opacity=\"{}\"", num(rest.opacity));
88                    }
89                }
90                Mode::Frame { t, .. } => {
91                    let now = group.motion.at(*t);
92                    let _ = write!(out, "<g transform=\"{}\"", now.attr(group.origin));
93                    if now.opacity < 1.0 {
94                        let _ = write!(out, " opacity=\"{}\"", num(now.opacity));
95                    }
96                }
97            }
98            out.push('>');
99            write_children(out, group, mode);
100            out.push_str("</g>");
101        }
102    }
103}
104
105fn write_children(out: &mut String, group: &Group, mode: &Mode) {
106    for child in &group.children {
107        write_node(out, child, mode);
108    }
109}
110
111fn escape(text: &str) -> String {
112    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
113}
114
115/// The JavaScript expression (for `data-attr:transform`) that moves the pupils to
116/// `$ex`, `$ey`; and the one that turns a pointer position into the gaze `$gx`, `$gy`.
117/// Built from the constants the app's snapping uses, so page and video cannot differ.
118pub fn eyes_attrs() -> String {
119    format!("data-attr:transform=\"{}\"", eyes_transform_expr())
120}
121
122pub fn eyes_transform_expr() -> String {
123    use crate::scene::{GAZE_REACH, GAZE_STEP};
124    let axis = |signal: &str, reach: f32| format!("Math.round(${signal}*{}/{})*{}", num(reach), num(GAZE_STEP), num(GAZE_STEP));
125    format!("'translate(' + {} + ' ' + {} + ')'", axis("gx", GAZE_REACH.0), axis("gy", GAZE_REACH.1))
126}
127
128/// The expression for `data-on:pointermove`, turning the pointer into a gaze in `$gx`, `$gy`.
129/// `el` is the element carrying the handler (the figure holding the cat), whose box is the
130/// drawing area, so the pointer is measured from the cat's middle in units of its half-width
131/// and stops at the edge of a circle three half-widths out (the app clamps to one: a pointer
132/// on a page is a long way off, and the eyes should still show where it is).
133pub fn pointer_to_gaze_expr() -> String {
134    let (x0, y0, w, h) = VIEW;
135    let (cx, cy) = CENTER;
136    format!(
137        "const b = el.getBoundingClientRect(); const u = Math.min(b.width / {w}, b.height / {h}); \
138         const ox = b.left + (b.width - {w} * u) / 2 + ({cx} - ({x0})) * u; \
139         const oy = b.top + (b.height - {h} * u) / 2 + ({cy} - ({y0})) * u; \
140         let dx = (evt.clientX - ox) / ({half} * u * 3), dy = (evt.clientY - oy) / ({half} * u * 3); \
141         const d = Math.hypot(dx, dy); if (d > 1) {{ dx /= d; dy /= d }} \
142         $gx = dx; $gy = dy",
143        half = num(HALF_WIDTH),
144    )
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::mood::Mood;
151    use crate::mascot::Mascot;
152    use crate::scene::scene;
153
154    fn live(mood: Mood) -> String {
155        svg(&scene(Mascot::Grey, mood), "a cat", &Mode::Live { eyes_attrs: &eyes_attrs() })
156    }
157
158    fn frame(mood: Mood, t: f32) -> String {
159        svg(&scene(Mascot::Grey, mood), "a cat", &Mode::Frame { t, gaze: (1.0, 0.0) })
160    }
161
162    #[test]
163    fn markup_is_balanced_for_every_mood_in_both_modes() {
164        for mood in Mood::ALL {
165            for markup in [live(mood), frame(mood, 0.37)] {
166                assert_eq!(markup.matches("<g ").count(), markup.matches("</g>").count(), "{mood:?}");
167                assert!(markup.starts_with("<svg") && markup.ends_with("</svg>"));
168                assert!(!markup.contains("NaN") && !markup.contains("inf"), "{mood:?}");
169            }
170        }
171    }
172
173    #[test]
174    fn a_live_scene_binds_its_pupils_to_the_pointer_and_a_frame_does_not() {
175        assert!(live(Mood::Idle).contains("data-attr:transform"));
176        assert!(!frame(Mood::Idle, 0.0).contains("data-attr"));
177        // Thinking looks where it looks, whatever the pointer does.
178        assert!(!live(Mood::Thinking).contains("data-attr"));
179    }
180
181    #[test]
182    fn a_frame_carries_no_classes_and_a_live_scene_carries_the_motion_classes() {
183        assert!(!frame(Mood::Dizzy, 1.0).contains("class=\"m-"));
184        let live = live(Mood::Dizzy);
185        for class in ["m-sway", "m-tilt", "m-breathe", "m-gap-3", "m-orbit-2"] {
186            assert!(live.contains(class), "{class}");
187        }
188    }
189
190    #[test]
191    fn a_frame_changes_with_time_and_gaze() {
192        assert_ne!(frame(Mood::Idle, 0.0), frame(Mood::Idle, 0.3));
193        let a = svg(&scene(Mascot::Grey, Mood::Idle), "x", &Mode::Frame { t: 0.5, gaze: (1.0, 0.0) });
194        let b = svg(&scene(Mascot::Grey, Mood::Idle), "x", &Mode::Frame { t: 0.5, gaze: (-1.0, 0.0) });
195        assert_ne!(a, b);
196    }
197
198    #[test]
199    fn the_label_is_escaped() {
200        let s = svg(&scene(Mascot::Grey, Mood::Idle), "a \"cat\" <b>", &Mode::Frame { t: 0.0, gaze: (0.0, 0.0) });
201        assert!(s.contains("aria-label=\"a &quot;cat&quot; &lt;b&gt;\""));
202    }
203
204    #[test]
205    fn the_pupil_expression_uses_the_apps_reach_and_step() {
206        assert_eq!(eyes_transform_expr(), "'translate(' + Math.round($gx*1.5/0.5)*0.5 + ' ' + Math.round($gy*1/0.5)*0.5 + ')'");
207    }
208}