Skip to main content

egui_map/map/
animation.rs

1//! Built-in animation effects for nodes.
2//!
3//! Two families, distinguished by how they handle time and by when they stop:
4//!
5//! - **Event-driven** effects ([`Animation::pulse`], [`Animation::ripple`],
6//!   [`Animation::countdown_arc`], [`Animation::scale_in`],
7//!   [`Animation::crosshair`]) are anchored to the [`Instant`] an event
8//!   happened and **terminate**: they return `true` while still playing and
9//!   `false` once finished, so the caller can drop the entry and stop
10//!   repainting.
11//! - **Persistent** effects ([`Animation::halo`], [`Animation::blink`],
12//!   [`Animation::orbit`]) never end. They take the **frame time** in seconds
13//!   (`ui.input(|i| i.time)`) rather than an `Instant`, so every element
14//!   animated in the same frame shares one clock and cannot drift apart.
15//!
16//! Persistent effects require the caller to keep requesting repaints, which
17//! turns an idle app into one redrawing continuously — use them for a handful
18//! of elements, not for every node.
19//!
20//! Both families are reached through
21//! [`Map::node`](crate::map::Map::node); see [`NodeHandle`](crate::map::NodeHandle).
22//! They are also useful from a custom
23//! [`NodeTemplate`](crate::map::objects::NodeTemplate): call them from
24//! `notification_ui` / `marker_ui` instead of reimplementing the effect. When
25//! you do, remember to call `ui.ctx().request_repaint()` yourself — the widget
26//! only does that for its own built-in path.
27
28use egui::{
29    Color32, Painter, Pos2, Shape, Stroke,
30    epaint::{CircleShape, PathShape},
31};
32use std::f32::consts::TAU;
33use std::time::Instant;
34
35/// How long [`Animation::pulse`] plays, in seconds.
36pub const PULSE_DURATION: f32 = 3.5;
37/// How long [`Animation::ripple`] plays, in seconds.
38pub const RIPPLE_DURATION: f32 = 3.5;
39/// How long [`Animation::countdown_arc`] takes to empty, in seconds.
40pub const COUNTDOWN_DURATION: f32 = 5.0;
41/// How long [`Animation::scale_in`] plays, in seconds.
42pub const SCALE_IN_DURATION: f32 = 0.45;
43/// How long [`Animation::crosshair`] takes to converge, in seconds.
44pub const CROSSHAIR_DURATION: f32 = 0.6;
45
46/// Returns `color` with its alpha replaced by `alpha` (clamped to `0.0..=1.0`).
47fn with_alpha(color: Color32, alpha: f32) -> Color32 {
48    Color32::from_rgba_unmultiplied(
49        color.r(),
50        color.g(),
51        color.b(),
52        (255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
53    )
54}
55
56/// Seconds elapsed since `initial_time`.
57fn elapsed(initial_time: Instant) -> f32 {
58    Instant::now().duration_since(initial_time).as_secs_f32()
59}
60
61/// A `0 -> 1 -> 0` triangle wave of the given `period`, in seconds.
62fn triangle_wave(time: f32, period: f32) -> f32 {
63    let phase = (time / period).rem_euclid(1.0);
64    1.0 - (2.0 * phase - 1.0).abs()
65}
66
67/// Overshooting ease-out, so a scale-in settles with a small bounce.
68fn ease_out_back(x: f32) -> f32 {
69    const C1: f32 = 1.701_58;
70    const C3: f32 = C1 + 1.0;
71    let x1 = x - 1.0;
72    1.0 + C3 * x1 * x1 * x1 + C1 * x1 * x1
73}
74
75/// Factory for the built-in node animations.
76///
77/// See the [module docs](self) for the difference between the event-driven and
78/// persistent families.
79pub struct Animation {}
80
81impl Animation {
82    // ---------------------------------------------------------------- events
83
84    /// One frame of an expanding, fading disc centred on `center`.
85    ///
86    /// Reads as *"one thing happened here"*. Plays for [`PULSE_DURATION`].
87    /// Returns `true` while still playing.
88    pub fn pulse(
89        painter: &Painter,
90        center: Pos2,
91        zoom: f32,
92        initial_time: Instant,
93        color: Color32,
94    ) -> bool {
95        let secs = elapsed(initial_time);
96        let radius = (4.00 + (40.00 * secs)) * zoom;
97        let transparency = (1.00 - (secs / PULSE_DURATION).abs()).max(0.0);
98        painter.add(Shape::Circle(CircleShape::filled(
99            center,
100            radius,
101            with_alpha(color, transparency),
102        )));
103        secs < PULSE_DURATION
104    }
105
106    /// One frame of three staggered expanding rings.
107    ///
108    /// Where [`Animation::pulse`] reads as a single event, the repetition here
109    /// reads as *"activity is ongoing"*. Plays for [`RIPPLE_DURATION`].
110    /// Returns `true` while still playing.
111    pub fn ripple(
112        painter: &Painter,
113        center: Pos2,
114        zoom: f32,
115        initial_time: Instant,
116        color: Color32,
117    ) -> bool {
118        const RINGS: usize = 3;
119        let secs = elapsed(initial_time);
120        let stagger = RIPPLE_DURATION / RINGS as f32;
121
122        let mut shapes = Vec::with_capacity(RINGS);
123        for ring in 0..RINGS {
124            let local = secs - ring as f32 * stagger;
125            if !(0.0..RIPPLE_DURATION).contains(&local) {
126                continue;
127            }
128            let progress = local / RIPPLE_DURATION;
129            shapes.push(Shape::Circle(CircleShape::stroke(
130                center,
131                (4.0 + 36.0 * progress) * zoom,
132                Stroke::new(2.0 * zoom, with_alpha(color, 1.0 - progress)),
133            )));
134        }
135        painter.extend(shapes);
136        secs < RIPPLE_DURATION
137    }
138
139    /// One frame of a ring that empties clockwise from 12 o'clock.
140    ///
141    /// The remaining arc is the remaining fraction of [`COUNTDOWN_DURATION`],
142    /// which makes it a natural fit for *"how old is this information"*.
143    /// Returns `true` while still playing.
144    pub fn countdown_arc(
145        painter: &Painter,
146        center: Pos2,
147        zoom: f32,
148        initial_time: Instant,
149        color: Color32,
150    ) -> bool {
151        // Segments in a full turn; the arc draws a prefix of these.
152        const STEPS: usize = 48;
153        let secs = elapsed(initial_time);
154        let remaining = (1.0 - secs / COUNTDOWN_DURATION).clamp(0.0, 1.0);
155        let radius = 10.0 * zoom;
156
157        let count = (STEPS as f32 * remaining).round() as usize;
158        if count >= 1 {
159            let points = (0..=count)
160                .map(|i| {
161                    // Start at 12 o'clock and sweep clockwise. Screen y grows
162                    // downwards, so a growing angle already turns clockwise.
163                    let angle = TAU * (i as f32 / STEPS as f32) - TAU / 4.0;
164                    Pos2::new(
165                        center.x + radius * angle.cos(),
166                        center.y + radius * angle.sin(),
167                    )
168                })
169                .collect();
170            painter.add(Shape::Path(PathShape::line(
171                points,
172                Stroke::new(2.0 * zoom, with_alpha(color, 1.0)),
173            )));
174        }
175        secs < COUNTDOWN_DURATION
176    }
177
178    /// One frame of a disc that grows past its final size and settles back.
179    ///
180    /// Meant for nodes that just appeared. Plays for [`SCALE_IN_DURATION`].
181    /// Returns `true` while still playing.
182    pub fn scale_in(
183        painter: &Painter,
184        center: Pos2,
185        zoom: f32,
186        initial_time: Instant,
187        color: Color32,
188    ) -> bool {
189        let secs = elapsed(initial_time);
190        let progress = (secs / SCALE_IN_DURATION).clamp(0.0, 1.0);
191        let radius = 8.0 * zoom * ease_out_back(progress).max(0.0);
192        painter.add(Shape::Circle(CircleShape::filled(
193            center,
194            radius,
195            with_alpha(color, 1.0 - progress),
196        )));
197        secs < SCALE_IN_DURATION
198    }
199
200    /// One frame of four ticks converging onto the node.
201    ///
202    /// Reads as *"target acquired"*; pairs well with selection. Plays for
203    /// [`CROSSHAIR_DURATION`]. Returns `true` while still playing.
204    pub fn crosshair(
205        painter: &Painter,
206        center: Pos2,
207        zoom: f32,
208        initial_time: Instant,
209        color: Color32,
210    ) -> bool {
211        let secs = elapsed(initial_time);
212        let progress = (secs / CROSSHAIR_DURATION).clamp(0.0, 1.0);
213        // Ticks travel from far away down to just outside the node, and fade
214        // out over the last third so they do not linger on top of it.
215        let far = (30.0 - 18.0 * progress) * zoom;
216        let near = far - 8.0 * zoom;
217        let alpha = if progress < 0.66 {
218            1.0
219        } else {
220            1.0 - (progress - 0.66) / 0.34
221        };
222        let stroke = Stroke::new(2.0 * zoom, with_alpha(color, alpha));
223
224        let mut shapes = Vec::with_capacity(4);
225        for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
226            shapes.push(Shape::line_segment(
227                [
228                    Pos2::new(center.x + dx * far, center.y + dy * far),
229                    Pos2::new(center.x + dx * near, center.y + dy * near),
230                ],
231                stroke,
232            ));
233        }
234        painter.extend(shapes);
235        secs < CROSSHAIR_DURATION
236    }
237
238    // ------------------------------------------------------------ persistent
239
240    /// One frame of a ring whose opacity breathes in and out.
241    ///
242    /// For lasting state — *"you are here"*, *"this system is camped"*. `time`
243    /// is the frame time in seconds (`ui.input(|i| i.time)`).
244    ///
245    /// The radius has a floor in screen pixels so the halo does not vanish
246    /// when the map is zoomed far out.
247    pub fn halo(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
248        const PERIOD: f32 = 2.0;
249        let alpha = 0.30 + 0.45 * triangle_wave(time, PERIOD);
250        let radius = (9.0 * zoom).max(5.0);
251        painter.add(Shape::Circle(CircleShape::stroke(
252            center,
253            radius,
254            Stroke::new((2.0 * zoom).max(1.5), with_alpha(color, alpha)),
255        )));
256    }
257
258    /// One frame of a thick ring blinking on and off.
259    ///
260    /// This is the effect markers have always used, factored out of the widget
261    /// so it can be selected and reused like any other. `time` is the frame
262    /// time in seconds.
263    pub fn blink(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
264        const PERIOD: f32 = 2.55;
265        painter.add(Shape::Circle(CircleShape::stroke(
266            center,
267            4.0 * zoom,
268            Stroke::new(9.0 * zoom, with_alpha(color, triangle_wave(time, PERIOD))),
269        )));
270    }
271
272    /// One frame of a dot orbiting the node, with a faint guide ring.
273    ///
274    /// Reads as *"under observation"*. `time` is the frame time in seconds.
275    pub fn orbit(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
276        const PERIOD: f32 = 3.0;
277        let radius = (12.0 * zoom).max(7.0);
278        let angle = TAU * (time / PERIOD).rem_euclid(1.0);
279        let dot = Pos2::new(
280            center.x + radius * angle.cos(),
281            center.y + radius * angle.sin(),
282        );
283        painter.extend([
284            Shape::Circle(CircleShape::stroke(
285                center,
286                radius,
287                Stroke::new(1.0, with_alpha(color, 0.25)),
288            )),
289            Shape::Circle(CircleShape::filled(
290                dot,
291                (2.5 * zoom).max(2.0),
292                with_alpha(color, 1.0),
293            )),
294        ]);
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use egui::{Context, LayerId, Rect, Vec2};
302    use std::time::Duration;
303
304    fn headless_painter() -> Painter {
305        Painter::new(
306            Context::default(),
307            LayerId::background(),
308            Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 100.0)),
309        )
310    }
311
312    /// Every event-driven effect, with the duration it is supposed to run for.
313    #[allow(clippy::type_complexity)]
314    fn event_effects() -> Vec<(
315        &'static str,
316        fn(&Painter, Pos2, f32, Instant, Color32) -> bool,
317        f32,
318    )> {
319        vec![
320            ("pulse", Animation::pulse, PULSE_DURATION),
321            ("ripple", Animation::ripple, RIPPLE_DURATION),
322            (
323                "countdown_arc",
324                Animation::countdown_arc,
325                COUNTDOWN_DURATION,
326            ),
327            ("scale_in", Animation::scale_in, SCALE_IN_DURATION),
328            ("crosshair", Animation::crosshair, CROSSHAIR_DURATION),
329        ]
330    }
331
332    #[test]
333    fn event_effects_report_running_then_finished() {
334        let painter = headless_painter();
335        for (name, effect, duration) in event_effects() {
336            assert!(
337                effect(&painter, Pos2::ZERO, 1.0, Instant::now(), Color32::RED),
338                "{name} must report it is still running when it just started"
339            );
340
341            let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
342            assert!(
343                !effect(&painter, Pos2::ZERO, 1.0, long_past, Color32::RED),
344                "{name} must report it is finished once its duration has passed"
345            );
346        }
347    }
348
349    #[test]
350    fn every_event_effect_stays_under_the_orphan_sweep() {
351        // `Map` drops notifications older than 10s as a safety net. An effect
352        // that outlived it would be cut off mid-play.
353        for (name, _, duration) in event_effects() {
354            assert!(
355                duration < 10.0,
356                "{name} lasts {duration}s, which the 10s orphan sweep would truncate"
357            );
358        }
359    }
360
361    #[test]
362    fn persistent_effects_run_at_any_time() {
363        let painter = headless_painter();
364        for time in [0.0, 0.7, 1.3, 60.0] {
365            Animation::halo(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
366            Animation::blink(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
367            Animation::orbit(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
368        }
369    }
370
371    #[test]
372    fn triangle_wave_goes_up_and_back_down() {
373        assert_eq!(triangle_wave(0.0, 2.0), 0.0);
374        assert_eq!(triangle_wave(1.0, 2.0), 1.0);
375        assert!(triangle_wave(2.0, 2.0).abs() < 1e-6);
376        assert!((triangle_wave(3.0, 2.0) - 1.0).abs() < 1e-6);
377        for step in 0..200 {
378            let v = triangle_wave(step as f32 * 0.05, 2.55);
379            assert!((0.0..=1.0).contains(&v), "{v} out of range");
380        }
381    }
382
383    #[test]
384    fn ease_out_back_overshoots_then_settles() {
385        assert_eq!(ease_out_back(0.0), 0.0);
386        assert!((ease_out_back(1.0) - 1.0).abs() < 1e-5);
387        let peak = (0..=100)
388            .map(|i| ease_out_back(i as f32 / 100.0))
389            .fold(f32::MIN, f32::max);
390        assert!(
391            peak > 1.0,
392            "ease_out_back should overshoot, peaked at {peak}"
393        );
394    }
395
396    #[test]
397    fn with_alpha_clamps_out_of_range_values() {
398        assert_eq!(with_alpha(Color32::RED, 2.0).a(), 255);
399        assert_eq!(with_alpha(Color32::RED, -1.0).a(), 0);
400        assert_eq!(with_alpha(Color32::RED, 1.0), Color32::RED);
401    }
402}