A scene as SVG. Two modes: Live, for the page, where each moving group carries a class
(the stylesheet in css.rs plays it) and the pupils carry whatever attributes the caller
binds the pointer with; and Frame, for the preview, where every motion is baked in at a
time and nothing needs a stylesheet.
6use std::fmt::Write;
eyes_attrs is raw markup for the pupils' <g>, written by the caller (the Datastar
binding). It is never built from a visitor's input.
15 Live { eyes_attrs: &'a str },
t seconds in, with the pupils looking gaze (each axis -1..1).
Just the shapes, to be placed in a larger drawing.
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}
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('&', "&").replace('<', "<").replace('>', ">").replace('"', """) 113}
The JavaScript expression (for data-attr:transform) that moves the pupils to
$ex, $ey; and the one that turns a pointer position into the gaze $gx, $gy.
Built from the constants the app's snapping uses, so page and video cannot differ.
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}
The expression for data-on:pointermove, turning the pointer into a gaze in $gx, $gy.
el is the element carrying the handler (the figure holding the cat), whose box is the
drawing area, so the pointer is measured from the cat's middle in units of its half-width
and stops at the edge of a circle three half-widths out (the app clamps to one: a pointer
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}
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 "cat" <b>\"")); 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}