How a group moves, as a function of time. The site plays it as CSS keyframes sampled from
at and its preview video bakes at(t) into each frame; the app evaluates the same
formulas in Kotlin (art/Motion.kt), and fixtures/frames.txt holds the two to each other.
5use std::f32::consts::TAU;
A transform and an opacity, applied about the group's origin.
18impl Xf { 19 pub const IDENTITY: Xf = Xf { tx: 0.0, ty: 0.0, rotate: 0.0, sx: 1.0, sy: 1.0, opacity: 1.0 }; 20 const fn translate(tx: f32, ty: f32) -> Xf { 21 Xf { tx, ty, ..Xf::IDENTITY } 22 } 23 const fn scale_y(sy: f32) -> Xf { 24 Xf { sy, ..Xf::IDENTITY } 25 } 26 const fn fade(opacity: f32) -> Xf { 27 Xf { opacity, ..Xf::IDENTITY } 28 }
For a CSS transform (the same three functions in the same order every time, so that
keyframes interpolate).
For an SVG transform attribute, about origin.
44 pub fn attr(&self, origin: (f32, f32)) -> String { 45 format!( 46 "translate({} {}) translate({} {}) rotate({}) scale({} {}) translate({} {})", 47 num(origin.0), 48 num(origin.1), 49 num(self.tx), 50 num(self.ty), 51 num(self.rotate), 52 num(self.sx), 53 num(self.sy), 54 num(-origin.0), 55 num(-origin.1) 56 ) 57 } 58}
A number as short as it can be written: no trailing zeros, no -0.
The standing body breathing.
71 Breathe,
The sleeping body breathing, slower and deeper.
73 BreatheAsleep,
Dizzy: the whole cat swaying.
75 Sway,
Tickled: the tail whipping about.
80 TailFlick,
Tickled: the whole cat shaking with laughter.
82 Shiver,
Working: a paw on the keys, 0 the left and 1 the right, taking turns.
84 Type(u8),
Busy: a paw thrown up and caught, 0 the left and 1 the right, in opposition.
86 Bob(u8),
Busy: one of three balls on its arc over the head. Drawn about its own middle.
88 Ball(u8),
Tickled: a heart floating up and fading, 0 or 1. Drawn at its start.
90 Heart(u8),
Tangled: a drop of sweat sliding down and fading.
92 Drip,
Busy: the eyes following the balls round.
94 Scan,
The eyes shutting for an instant every four seconds.
96 Blink,
The mouth while speaking, flapping on a beat.
98 MouthFlap,
Listening: the sound waves round the head, 0 the near one and 1 the far.
100 Pulse(u8),
Thinking: the three dots lighting in turn.
102 Dot(u8),
Dizzy: the gap walking round the ring that makes the spiral. One of eight cells.
104 Gap(u8),
Dizzy: the three stars circling the head.
106 Orbit(u8),
111impl Motion {
Every motion that exists, for the stylesheet. A test checks no scene uses one that is missing, so a new variant cannot be used and left out of the CSS.
114 pub fn all() -> Vec<Motion> { 115 let mut all = vec![ 116 Motion::Breathe, 117 Motion::BreatheAsleep, 118 Motion::Sway, 119 Motion::Tilt, 120 Motion::TailWag, 121 Motion::TailFlick, 122 Motion::Shiver, 123 Motion::Drip, 124 Motion::Scan, 125 Motion::Blink, 126 Motion::MouthFlap, 127 ]; 128 all.extend((0..2).map(Motion::Type)); 129 all.extend((0..2).map(Motion::Bob)); 130 all.extend((0..3).map(Motion::Ball)); 131 all.extend((0..2).map(Motion::Heart)); 132 all.extend((0..2).map(Motion::Pulse)); 133 all.extend((0..3).map(Motion::Dot)); 134 all.extend((0..8).map(Motion::Gap)); 135 all.extend((0..3).map(Motion::Orbit)); 136 all.extend((0..3).map(Motion::Rise)); 137 all 138 }
140 pub fn name(self) -> String { 141 match self { 142 Motion::Still => "still".into(), 143 Motion::Breathe => "breathe".into(), 144 Motion::BreatheAsleep => "breathe-asleep".into(), 145 Motion::Sway => "sway".into(), 146 Motion::Tilt => "tilt".into(), 147 Motion::TailWag => "tail-wag".into(), 148 Motion::TailFlick => "tail-flick".into(), 149 Motion::Shiver => "shiver".into(), 150 Motion::Type(i) => format!("type-{i}"), 151 Motion::Bob(i) => format!("bob-{i}"), 152 Motion::Ball(i) => format!("ball-{i}"), 153 Motion::Heart(i) => format!("heart-{i}"), 154 Motion::Drip => "drip".into(), 155 Motion::Scan => "scan".into(), 156 Motion::Blink => "blink".into(), 157 Motion::MouthFlap => "mouth-flap".into(), 158 Motion::Pulse(i) => format!("pulse-{i}"), 159 Motion::Dot(i) => format!("dot-{i}"), 160 Motion::Gap(i) => format!("gap-{i}"), 161 Motion::Orbit(i) => format!("orbit-{i}"), 162 Motion::Rise(i) => format!("rise-{i}"), 163 } 164 }
Seconds until it repeats exactly.
167 pub fn period(self) -> f32 { 168 match self { 169 Motion::Still => 1.0, 170 Motion::Breathe | Motion::TailWag => TAU / 2.0, 171 Motion::BreatheAsleep => TAU / 1.4, 172 Motion::Sway | Motion::Tilt => TAU / 5.0, 173 Motion::Orbit(_) => TAU / 4.0, 174 Motion::TailFlick => TAU / 18.0, 175 Motion::Shiver => TAU / 40.0, 176 Motion::Type(_) => TAU / 14.0, 177 Motion::Bob(_) => TAU / 7.0, 178 Motion::Ball(_) => 1.0 / 1.4, 179 Motion::Heart(_) => 1.0 / 0.9, 180 Motion::Drip => 1.0 / 0.8, 181 Motion::Scan => TAU / 3.5, 182 Motion::Blink => 4.0, 183 Motion::MouthFlap => TAU / 2.0 / 13.0, 184 Motion::Pulse(_) => TAU / 6.0, 185 Motion::Dot(_) => 1.2, 186 Motion::Gap(_) => 0.8, 187 Motion::Rise(_) => 1.0 / 0.35, 188 } 189 }
Whether a value holds until the next change (blinking, a dot lit or not) rather than sliding towards the next sample.
Where the group is at time t seconds.
198 pub fn at(self, t: f32) -> Xf { 199 match self { 200 Motion::Still => Xf::IDENTITY, 201 Motion::Breathe => Xf::scale_y(1.0 + 0.015 * (t * 2.0).sin()), 202 Motion::BreatheAsleep => Xf::scale_y(1.0 + 0.02 * (t * 1.4).sin()), 203 Motion::Sway => Xf::translate((t * 5.0).sin() * 0.7, 0.0), 204 Motion::Tilt => Xf { rotate: (t * 5.0).sin() * 6.0, ..Xf::IDENTITY }, 205 Motion::TailWag => Xf::translate(0.0, (t * 2.0).sin() * 0.4), 206 Motion::TailFlick => Xf::translate(0.0, (t * 18.0).sin()), 207 Motion::Shiver => Xf::translate((t * 40.0).sin() * 0.25, 0.0), 208 Motion::Type(i) => { 209 // One paw is down while the other is up; the app's `a` is 0.7 half of every cycle. 210 let first_down = (t * 14.0).sin() > 0.0; 211 Xf::translate(0.0, if (i == 0) == first_down { 0.7 } else { 0.0 }) 212 } 213 Motion::Bob(i) => { 214 let bob = (t * 7.0).sin() * 0.6; 215 Xf::translate(0.0, if i == 0 { bob } else { -bob }) 216 } 217 Motion::Ball(i) => { 218 let phase = (t * 1.4 + f32::from(i) / 3.0).rem_euclid(1.0); 219 Xf::translate(1.5 + phase * 12.0, 5.5 - 7.0 * 4.0 * phase * (1.0 - phase)) 220 } 221 Motion::Heart(i) => { 222 let phase = (t * 0.9 + f32::from(i) * 0.5).rem_euclid(1.0); 223 Xf { tx: 5.5 + f32::from(i) * 4.0, ty: 4.0 - phase * 5.0, opacity: (1.0 - phase).clamp(0.0, 1.0), ..Xf::IDENTITY } 224 } 225 Motion::Drip => { 226 let drip = (t * 0.8).rem_euclid(1.0); 227 Xf { ty: drip * 1.5, opacity: 1.0 - drip * 0.6, ..Xf::IDENTITY } 228 } 229 Motion::Scan => { 230 let snap = |v: f32| (v / 0.5).round() * 0.5; 231 Xf::translate(snap((t * 3.5).sin() * 1.5), -0.5) 232 } 233 Motion::Blink => Xf::scale_y(if t.rem_euclid(4.0) < 0.12 { 0.1 } else { 1.0 }), 234 Motion::MouthFlap => Xf::scale_y(0.5 + (t * 13.0).sin().abs() * 0.9), 235 Motion::Pulse(i) => { 236 let pulse = 0.5 + 0.5 * (t * 6.0).sin(); 237 Xf::fade(if i == 0 { 0.4 + 0.5 * pulse } else { 0.9 - 0.5 * pulse }) 238 } 239 Motion::Dot(i) => { 240 let lit = (t * 2.5).floor().rem_euclid(3.0) as u8; 241 Xf::fade(if i <= lit { 0.85 } else { 0.2 }) 242 } 243 Motion::Gap(i) => { 244 let gap = (t * 10.0).floor().rem_euclid(8.0) as u8; 245 Xf::fade(if i == gap { 0.0 } else { 1.0 }) 246 } 247 Motion::Orbit(i) => { 248 let a = t * 4.0 + f32::from(i) * TAU / 3.0; 249 Xf::translate(a.cos() * 4.5, a.sin() * 1.1) 250 } 251 Motion::Rise(i) => { 252 let phase = (t * 0.35 + f32::from(i) / 3.0).rem_euclid(1.0); 253 let s = 0.7 + phase * 0.7; 254 Xf { tx: phase * 2.5, ty: -phase * 5.0, sx: s, sy: s, opacity: (1.2 - phase).clamp(0.25, 1.0), ..Xf::IDENTITY } 255 } 256 } 257 }
Where it stands when nothing plays (reduced motion, or before the stylesheet loads): open eyes, every cell of the ring, the stars spread round the head.
261 pub fn rest(self) -> Xf { 262 match self { 263 Motion::MouthFlap => Xf::scale_y(1.0), 264 Motion::Dot(_) => Xf::fade(0.85), 265 Motion::Pulse(_) | Motion::Orbit(_) | Motion::Rise(_) | Motion::Ball(_) | Motion::Heart(_) | Motion::Bob(_) => self.at(0.0), 266 Motion::Type(_) => Xf::IDENTITY, 267 _ => Xf::IDENTITY, 268 } 269 } 270}
272#[cfg(test)] 273mod tests { 274 use super::*; 275 276 #[test] 277 fn every_motion_repeats_after_its_period() { 278 for motion in Motion::all() { 279 let p = motion.period(); 280 for t in [0.013, 0.31, 1.7] { 281 let (a, b) = (motion.at(t), motion.at(t + p)); 282 for (x, y) in [(a.tx, b.tx), (a.ty, b.ty), (a.rotate, b.rotate), (a.sx, b.sx), (a.sy, b.sy), (a.opacity, b.opacity)] { 283 assert!((x - y).abs() < 2e-3, "{} at {t}: {a:?} against {b:?}", motion.name()); 284 } 285 } 286 } 287 } 288 289 #[test] 290 fn names_are_unique_because_they_are_css_class_names() { 291 let mut names: Vec<String> = Motion::all().into_iter().map(Motion::name).collect(); 292 let n = names.len(); 293 names.sort(); 294 names.dedup(); 295 assert_eq!(names.len(), n); 296 } 297 298 #[test] 299 fn the_app_s_formulas_hold() { 300 // Cat.kt: breathe = 1 + 0.015 sin(2t); the blink is closed for the first 0.12 s of 4. 301 assert!((Motion::Breathe.at(0.7853982).sy - 1.015).abs() < 1e-4); 302 assert_eq!(Motion::Blink.at(0.05).sy, 0.1); 303 assert_eq!(Motion::Blink.at(0.2).sy, 1.0); 304 // The ring has exactly one gap at a time. 305 let open = (0..8).filter(|i| Motion::Gap(*i).at(0.35).opacity == 1.0).count(); 306 assert_eq!(open, 7); 307 // One dot is lit first, then two, then three. 308 let lit = |t| (0..3).filter(|i| Motion::Dot(*i).at(t).opacity > 0.5).count(); 309 assert_eq!((lit(0.0), lit(0.5), lit(0.9)), (1, 2, 3)); 310 } 311 312 #[test] 313 fn numbers_are_short() { 314 assert_eq!(num(1.0), "1"); 315 assert_eq!(num(-0.0001), "0"); 316 assert_eq!(num(0.12345), "0.123"); 317 } 318}