Skip to main content

egui_map/map/
animation.rs

1//! Built-in animation effects for nodes and segments.
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`], [`Animation::flash_decay`],
8//!   [`Animation::comet_once`], [`Animation::wipe`]) are anchored to the
9//!   [`Instant`] an event happened and **terminate**: they return `true`
10//!   while still playing and `false` once finished, so the caller can drop
11//!   the entry and stop repainting.
12//! - **Persistent** effects ([`Animation::halo`], [`Animation::blink`],
13//!   [`Animation::orbit`], [`Animation::comet`], [`Animation::dash`],
14//!   [`Animation::glow_band`], [`Animation::chevrons`]) never end. They take
15//!   the **frame time** in seconds (`ui.input(|i| i.time)`) rather than an
16//!   `Instant`, so every element animated in the same frame shares one clock
17//!   and cannot drift apart.
18//!
19//! Persistent effects require the caller to keep requesting repaints, which
20//! turns an idle app into one redrawing continuously — use them for a handful
21//! of elements, not for every node or segment.
22//!
23//! The node effects are reached through
24//! [`Map::node`](crate::map::Map::node); see [`NodeHandle`](crate::map::NodeHandle).
25//! They are also useful from a custom
26//! [`NodeTemplate`](crate::map::objects::NodeTemplate): call them from
27//! `notification_ui` / `marker_ui` instead of reimplementing the effect. When
28//! you do, remember to call `ui.ctx().request_repaint()` yourself — the widget
29//! only does that for its own built-in path.
30//!
31//! The segment effects ([`Animation::flash_decay`], [`Animation::comet_once`],
32//! [`Animation::wipe`], [`Animation::comet`], [`Animation::dash`],
33//! [`Animation::glow_band`], [`Animation::chevrons`]) are reached the same way, through
34//! [`Map::segment`](crate::map::Map::segment); see
35//! [`SegmentHandle`](crate::map::SegmentHandle). A custom
36//! [`SegmentTemplate`](crate::map::objects::SegmentTemplate) calls them from
37//! `segment_notification_ui` / `segment_state_ui`, remembering to call
38//! `painter.ctx().request_repaint()` itself.
39
40use super::objects::CometDirection;
41use egui::{
42    Color32, ColorImage, Context, Id, Mesh, Painter, Pos2, Shape, Stroke, TextureFilter,
43    TextureHandle, TextureOptions, TextureWrapMode, Vec2,
44    epaint::{CircleShape, PathShape, Vertex},
45    pos2,
46};
47use std::f32::consts::TAU;
48use std::time::Instant;
49
50/// How long [`Animation::pulse`] plays, in seconds.
51pub const PULSE_DURATION: f32 = 3.5;
52/// How long [`Animation::ripple`] plays, in seconds.
53pub const RIPPLE_DURATION: f32 = 3.5;
54/// How long [`Animation::countdown_arc`] takes to empty, in seconds.
55pub const COUNTDOWN_DURATION: f32 = 5.0;
56/// How long [`Animation::scale_in`] plays, in seconds.
57pub const SCALE_IN_DURATION: f32 = 0.45;
58/// How long [`Animation::crosshair`] takes to converge, in seconds.
59pub const CROSSHAIR_DURATION: f32 = 0.6;
60/// How long [`Animation::flash_decay`] takes to fade back out, in seconds.
61pub const FLASH_DECAY_DURATION: f32 = 1.0;
62/// How long [`Animation::comet`] takes for one end-to-end pass, in seconds.
63pub const COMET_PERIOD: f32 = 1.6;
64/// How long a single [`Animation::comet_once`] pass takes to cross the
65/// segment, in seconds.
66pub const COMET_TRAVEL_DURATION: f32 = 1.2;
67/// Length, in **screen pixels**, of one dash-plus-gap repeat of
68/// [`Animation::dash`]. Deliberately not scaled by zoom, same as the dash
69/// speed, so the pattern doesn't stretch as the map is zoomed -- matching how
70/// node/label text is sized in screen space rather than map space.
71pub const DASH_PERIOD_PX: f32 = 24.0;
72/// How many repeats of the dash pattern [`Animation::dash`] slides through
73/// per second ("marching ants" speed).
74pub const DASH_SPEED: f32 = 0.6;
75/// Width, before the `zoom` multiplier, of the ribbon [`Animation::dash`]
76/// paints.
77pub const DASH_WIDTH: f32 = 3.0;
78/// How long [`Animation::wipe`] takes to draw the line in, in seconds.
79pub const WIPE_DURATION: f32 = 0.9;
80/// How long one full traverse-and-loop of [`Animation::glow_band`] takes, in
81/// seconds -- the band fades out past one end before it reappears at the
82/// other, so this covers the whole cycle, not just the visible crossing.
83pub const GLOW_BAND_PERIOD: f32 = 2.2;
84/// Length, in **screen pixels**, of the visible glow band
85/// [`Animation::glow_band`] paints. Deliberately not scaled by zoom, same
86/// reasoning as [`DASH_PERIOD_PX`].
87pub const GLOW_BAND_LENGTH_PX: f32 = 40.0;
88/// Width, before the `zoom` multiplier, of the ribbon [`Animation::glow_band`]
89/// paints.
90pub const GLOW_BAND_THICKNESS: f32 = 5.0;
91/// Length, in **screen pixels**, of one chevron repeat of
92/// [`Animation::chevrons`]. Deliberately not scaled by zoom, same reasoning
93/// as [`DASH_PERIOD_PX`].
94pub const CHEVRON_PERIOD_PX: f32 = 28.0;
95/// How many repeats of the chevron pattern [`Animation::chevrons`] slides
96/// through per second.
97pub const CHEVRON_SPEED: f32 = 0.5;
98/// Width, before the `zoom` multiplier, of the ribbon [`Animation::chevrons`]
99/// paints.
100pub const CHEVRON_WIDTH: f32 = 10.0;
101
102/// Returns `color` with its alpha replaced by `alpha` (clamped to `0.0..=1.0`).
103fn with_alpha(color: Color32, alpha: f32) -> Color32 {
104    Color32::from_rgba_unmultiplied(
105        color.r(),
106        color.g(),
107        color.b(),
108        (255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
109    )
110}
111
112/// Seconds elapsed since `initial_time`.
113fn elapsed(initial_time: Instant) -> f32 {
114    Instant::now().duration_since(initial_time).as_secs_f32()
115}
116
117/// A `0 -> 1 -> 0` triangle wave of the given `period`, in seconds.
118fn triangle_wave(time: f32, period: f32) -> f32 {
119    let phase = (time / period).rem_euclid(1.0);
120    1.0 - (2.0 * phase - 1.0).abs()
121}
122
123/// Overshooting ease-out, so a scale-in settles with a small bounce.
124fn ease_out_back(x: f32) -> f32 {
125    const C1: f32 = 1.701_58;
126    const C3: f32 = C1 + 1.0;
127    let x1 = x - 1.0;
128    1.0 + C3 * x1 * x1 * x1 + C1 * x1 * x1
129}
130
131/// Factory for the built-in node animations.
132///
133/// See the [module docs](self) for the difference between the event-driven and
134/// persistent families.
135pub struct Animation {}
136
137impl Animation {
138    // ---------------------------------------------------------------- events
139
140    /// One frame of an expanding, fading disc centred on `center`.
141    ///
142    /// Reads as *"one thing happened here"*. Plays for [`PULSE_DURATION`].
143    /// Returns `true` while still playing.
144    pub fn pulse(
145        painter: &Painter,
146        center: Pos2,
147        zoom: f32,
148        initial_time: Instant,
149        color: Color32,
150    ) -> bool {
151        let secs = elapsed(initial_time);
152        let radius = (4.00 + (40.00 * secs)) * zoom;
153        let transparency = (1.00 - (secs / PULSE_DURATION).abs()).max(0.0);
154        painter.add(Shape::Circle(CircleShape::filled(
155            center,
156            radius,
157            with_alpha(color, transparency),
158        )));
159        secs < PULSE_DURATION
160    }
161
162    /// One frame of three staggered expanding rings.
163    ///
164    /// Where [`Animation::pulse`] reads as a single event, the repetition here
165    /// reads as *"activity is ongoing"*. Plays for [`RIPPLE_DURATION`].
166    /// Returns `true` while still playing.
167    pub fn ripple(
168        painter: &Painter,
169        center: Pos2,
170        zoom: f32,
171        initial_time: Instant,
172        color: Color32,
173    ) -> bool {
174        const RINGS: usize = 3;
175        let secs = elapsed(initial_time);
176        let stagger = RIPPLE_DURATION / RINGS as f32;
177
178        let mut shapes = Vec::with_capacity(RINGS);
179        for ring in 0..RINGS {
180            let local = secs - ring as f32 * stagger;
181            if !(0.0..RIPPLE_DURATION).contains(&local) {
182                continue;
183            }
184            let progress = local / RIPPLE_DURATION;
185            shapes.push(Shape::Circle(CircleShape::stroke(
186                center,
187                (4.0 + 36.0 * progress) * zoom,
188                Stroke::new(2.0 * zoom, with_alpha(color, 1.0 - progress)),
189            )));
190        }
191        painter.extend(shapes);
192        secs < RIPPLE_DURATION
193    }
194
195    /// One frame of a ring that empties clockwise from 12 o'clock.
196    ///
197    /// The remaining arc is the remaining fraction of [`COUNTDOWN_DURATION`],
198    /// which makes it a natural fit for *"how old is this information"*.
199    /// Returns `true` while still playing.
200    pub fn countdown_arc(
201        painter: &Painter,
202        center: Pos2,
203        zoom: f32,
204        initial_time: Instant,
205        color: Color32,
206    ) -> bool {
207        // Segments in a full turn; the arc draws a prefix of these.
208        const STEPS: usize = 48;
209        let secs = elapsed(initial_time);
210        let remaining = (1.0 - secs / COUNTDOWN_DURATION).clamp(0.0, 1.0);
211        let radius = 10.0 * zoom;
212
213        let count = (STEPS as f32 * remaining).round() as usize;
214        if count >= 1 {
215            let points = (0..=count)
216                .map(|i| {
217                    // Start at 12 o'clock and sweep clockwise. Screen y grows
218                    // downwards, so a growing angle already turns clockwise.
219                    let angle = TAU * (i as f32 / STEPS as f32) - TAU / 4.0;
220                    Pos2::new(
221                        center.x + radius * angle.cos(),
222                        center.y + radius * angle.sin(),
223                    )
224                })
225                .collect();
226            painter.add(Shape::Path(PathShape::line(
227                points,
228                Stroke::new(2.0 * zoom, with_alpha(color, 1.0)),
229            )));
230        }
231        secs < COUNTDOWN_DURATION
232    }
233
234    /// One frame of a disc that grows past its final size and settles back.
235    ///
236    /// Meant for nodes that just appeared. Plays for [`SCALE_IN_DURATION`].
237    /// Returns `true` while still playing.
238    pub fn scale_in(
239        painter: &Painter,
240        center: Pos2,
241        zoom: f32,
242        initial_time: Instant,
243        color: Color32,
244    ) -> bool {
245        let secs = elapsed(initial_time);
246        let progress = (secs / SCALE_IN_DURATION).clamp(0.0, 1.0);
247        let radius = 8.0 * zoom * ease_out_back(progress).max(0.0);
248        painter.add(Shape::Circle(CircleShape::filled(
249            center,
250            radius,
251            with_alpha(color, 1.0 - progress),
252        )));
253        secs < SCALE_IN_DURATION
254    }
255
256    /// One frame of four ticks converging onto the node.
257    ///
258    /// Reads as *"target acquired"*; pairs well with selection. Plays for
259    /// [`CROSSHAIR_DURATION`]. Returns `true` while still playing.
260    pub fn crosshair(
261        painter: &Painter,
262        center: Pos2,
263        zoom: f32,
264        initial_time: Instant,
265        color: Color32,
266    ) -> bool {
267        let secs = elapsed(initial_time);
268        let progress = (secs / CROSSHAIR_DURATION).clamp(0.0, 1.0);
269        // Ticks travel from far away down to just outside the node, and fade
270        // out over the last third so they do not linger on top of it.
271        let far = (30.0 - 18.0 * progress) * zoom;
272        let near = far - 8.0 * zoom;
273        let alpha = if progress < 0.66 {
274            1.0
275        } else {
276            1.0 - (progress - 0.66) / 0.34
277        };
278        let stroke = Stroke::new(2.0 * zoom, with_alpha(color, alpha));
279
280        let mut shapes = Vec::with_capacity(4);
281        for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
282            shapes.push(Shape::line_segment(
283                [
284                    Pos2::new(center.x + dx * far, center.y + dy * far),
285                    Pos2::new(center.x + dx * near, center.y + dy * near),
286                ],
287                stroke,
288            ));
289        }
290        painter.extend(shapes);
291        secs < CROSSHAIR_DURATION
292    }
293
294    // -------------------------------------------------------- events/segment
295
296    /// One frame of a segment briefly thickening and brightening, then fading
297    /// back to nothing.
298    ///
299    /// The segment analogue of [`Animation::pulse`]: reads as *"something
300    /// happened on this route"*. Plays for [`FLASH_DECAY_DURATION`]. Returns
301    /// `true` while still playing.
302    pub fn flash_decay(
303        painter: &Painter,
304        a: Pos2,
305        b: Pos2,
306        zoom: f32,
307        initial_time: Instant,
308        color: Color32,
309    ) -> bool {
310        let secs = elapsed(initial_time);
311        let progress = (secs / FLASH_DECAY_DURATION).clamp(0.0, 1.0);
312        let width = (2.0 + 10.0 * (1.0 - progress)) * zoom;
313        painter.line_segment(
314            [a, b],
315            Stroke::new(width, with_alpha(color, 1.0 - progress)),
316        );
317        secs < FLASH_DECAY_DURATION
318    }
319
320    /// One frame of a single dot pass along the segment, then gone — the
321    /// event-driven counterpart to [`Animation::comet`]. `direction` picks
322    /// which endpoint it starts from. Plays for [`COMET_TRAVEL_DURATION`].
323    /// Returns `true` while still playing.
324    pub fn comet_once(
325        painter: &Painter,
326        a: Pos2,
327        b: Pos2,
328        zoom: f32,
329        initial_time: Instant,
330        color: Color32,
331        direction: CometDirection,
332    ) -> bool {
333        let secs = elapsed(initial_time);
334        let progress = (secs / COMET_TRAVEL_DURATION).clamp(0.0, 1.0);
335        let (from, to) = match direction {
336            CometDirection::Forward => (a, b),
337            CometDirection::Reverse => (b, a),
338        };
339        let pos = from + (to - from) * progress;
340        painter.add(Shape::Circle(CircleShape::filled(
341            pos,
342            (4.0 * zoom).max(2.5),
343            color,
344        )));
345        secs < COMET_TRAVEL_DURATION
346    }
347
348    /// One frame of the segment drawing itself in, from `a` towards `b`, then
349    /// gone. Reads as *"this route was just established"* — where
350    /// [`Animation::comet_once`] shows something moving along an existing
351    /// route, this shows the route itself appearing. Plays for
352    /// [`WIPE_DURATION`]. Returns `true` while still playing.
353    ///
354    /// Cheaper than the mesh technique [`Animation::dash`] uses: the
355    /// progressively-revealed portion is still just a straight line, so a
356    /// plain `line_segment` from `a` to the interpolated point suffices.
357    pub fn wipe(
358        painter: &Painter,
359        a: Pos2,
360        b: Pos2,
361        zoom: f32,
362        initial_time: Instant,
363        color: Color32,
364    ) -> bool {
365        let secs = elapsed(initial_time);
366        let progress = (secs / WIPE_DURATION).clamp(0.0, 1.0);
367        let leading_edge = a + (b - a) * progress;
368        painter.line_segment([a, leading_edge], Stroke::new(2.5 * zoom, color));
369        secs < WIPE_DURATION
370    }
371
372    // ----------------------------------------------------- persistent/segment
373
374    /// One frame of a dot travelling from `a` to `b` and looping back.
375    ///
376    /// Reads as *"this is the direction of flow"*. `time` is the frame time in
377    /// seconds; one full pass takes [`COMET_PERIOD`].
378    pub fn comet(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
379        let t = (time / COMET_PERIOD).rem_euclid(1.0);
380        let pos = a + (b - a) * t;
381        painter.add(Shape::Circle(CircleShape::filled(
382            pos,
383            (4.0 * zoom).max(2.5),
384            color,
385        )));
386    }
387
388    /// One frame of a segment drawn as a dashed line whose pattern slides
389    /// along it ("marching ants"). `time` is the frame time in seconds; the
390    /// pattern repeats every [`DASH_PERIOD_PX`] screen pixels and slides at
391    /// [`DASH_SPEED`] repeats per second.
392    ///
393    /// Two triangles textured with a small repeating strip (registered once
394    /// per [`egui::Context`] and reused after that), rather than one shape per
395    /// dash -- see the [module docs](self) for why that matters at scale. A
396    /// zero-length segment is skipped.
397    pub fn dash(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
398        let delta = b - a;
399        let len = delta.length();
400        if len <= f32::EPSILON {
401            return;
402        }
403        let dir = delta / len;
404        let normal = Vec2::new(-dir.y, dir.x) * (DASH_WIDTH * zoom * 0.5);
405        let phase = (time * DASH_SPEED).rem_euclid(1.0);
406        let u0 = phase;
407        let u1 = phase + len / DASH_PERIOD_PX;
408
409        let texture = Self::dash_texture(painter.ctx());
410        let mut mesh = Mesh::with_texture(texture.id());
411        mesh.vertices.extend([
412            Vertex {
413                pos: a + normal,
414                uv: pos2(u0, 0.5),
415                color,
416            },
417            Vertex {
418                pos: a - normal,
419                uv: pos2(u0, 0.5),
420                color,
421            },
422            Vertex {
423                pos: b + normal,
424                uv: pos2(u1, 0.5),
425                color,
426            },
427            Vertex {
428                pos: b - normal,
429                uv: pos2(u1, 0.5),
430                color,
431            },
432        ]);
433        mesh.indices.extend([0, 1, 2, 2, 1, 3]);
434        painter.add(mesh);
435    }
436
437    /// Returns the texture [`Animation::dash`] samples, creating and caching
438    /// it in the context's own temp data on first use so every segment (and
439    /// every frame) reuses the same GPU upload instead of re-registering one.
440    ///
441    /// A `[WHITE, alpha]` strip rather than an opaque/transparent one: the
442    /// vertex `color` tints it (egui multiplies `vertex.color * texel` when
443    /// painting a textured mesh), so the same texture works for every
444    /// segment's colour. The alpha ramps over a few texels at each edge of the
445    /// transition instead of stepping instantly, so a `Linear` sampler gives
446    /// the dash a soft edge rather than the aliasing a hard step would show
447    /// under magnification.
448    fn dash_texture(ctx: &Context) -> TextureHandle {
449        let id = Id::new("egui_map::dash_texture");
450        if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
451            return handle;
452        }
453
454        const WIDTH: usize = 32;
455        const FADE: usize = 3;
456        let half = WIDTH / 2;
457        let pixels = (0..WIDTH)
458            .map(|i| {
459                let alpha = if i < half - FADE {
460                    255
461                } else if i < half + FADE {
462                    let t = (i - (half - FADE)) as f32 / (2.0 * FADE as f32);
463                    (255.0 * (1.0 - t)).round() as u8
464                } else {
465                    0
466                };
467                Color32::from_white_alpha(alpha)
468            })
469            .collect();
470        let image = ColorImage::new([WIDTH, 1], pixels);
471        let handle = ctx.load_texture(
472            "egui_map::dash",
473            image,
474            TextureOptions {
475                magnification: TextureFilter::Linear,
476                minification: TextureFilter::Linear,
477                wrap_mode: TextureWrapMode::Repeat,
478                mipmap_mode: None,
479            },
480        );
481        ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
482        handle
483    }
484
485    /// One frame of a localized band of brightness travelling the length of
486    /// the segment and looping. `time` is the frame time in seconds; one full
487    /// traverse-and-loop takes [`GLOW_BAND_PERIOD`].
488    ///
489    /// Reads as *"flow"*, calmer than [`Animation::dash`]'s marching pattern
490    /// -- a single soft highlight rather than a repeating texture. Uses the
491    /// same textured-mesh technique as `dash`, but with a
492    /// [`TextureWrapMode::ClampToEdge`] sampler and a texture that is zero-alpha
493    /// at both edges: as the band's mapped position slides past `0.0` or
494    /// `1.0`, sampling clamps to that zero-alpha edge texel, so the band
495    /// fades out before either endpoint instead of popping back in like
496    /// `dash`'s repeating pattern would. A zero-length segment is skipped.
497    pub fn glow_band(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
498        let delta = b - a;
499        let len = delta.length();
500        if len <= f32::EPSILON {
501            return;
502        }
503        let dir = delta / len;
504        let normal = Vec2::new(-dir.y, dir.x) * (GLOW_BAND_THICKNESS * zoom * 0.5);
505
506        // Half-width of the visible band, as a fraction of the segment's own
507        // length -- capped at 0.5 so the band can never cover more than the
508        // whole segment.
509        let half_width_frac = (GLOW_BAND_LENGTH_PX * 0.5 / len).min(0.5);
510        // The band's peak travels from just before the start to just past the
511        // end and loops, rather than jumping straight from `1.0` back to
512        // `0.0` -- that extra span is what lets it fade out past each end.
513        let span = 1.0 + 2.0 * half_width_frac;
514        let t = (time / GLOW_BAND_PERIOD).rem_euclid(1.0);
515        let peak = -half_width_frac + t * span;
516        let texture_u = |frac: f32| 0.5 + (frac - peak) / (2.0 * half_width_frac);
517
518        let texture = Self::glow_band_texture(painter.ctx());
519        let mut mesh = Mesh::with_texture(texture.id());
520        let u_a = texture_u(0.0);
521        let u_b = texture_u(1.0);
522        mesh.vertices.extend([
523            Vertex {
524                pos: a + normal,
525                uv: pos2(u_a, 0.5),
526                color,
527            },
528            Vertex {
529                pos: a - normal,
530                uv: pos2(u_a, 0.5),
531                color,
532            },
533            Vertex {
534                pos: b + normal,
535                uv: pos2(u_b, 0.5),
536                color,
537            },
538            Vertex {
539                pos: b - normal,
540                uv: pos2(u_b, 0.5),
541                color,
542            },
543        ]);
544        mesh.indices.extend([0, 1, 2, 2, 1, 3]);
545        painter.add(mesh);
546    }
547
548    /// Returns the texture [`Animation::glow_band`] samples, creating and
549    /// caching it in the context's own temp data on first use -- same pattern
550    /// as [`Animation::dash_texture`].
551    ///
552    /// A symmetric tent-shaped alpha profile (zero at both edges, peaking at
553    /// the centre, smoothstepped rather than a hard linear ramp for a softer
554    /// glow) rather than `dash`'s step, and [`TextureWrapMode::ClampToEdge`]
555    /// instead of `Repeat` -- the zero-alpha edges are exactly what makes
556    /// sampling past `[0, 1]` fade out instead of wrapping.
557    fn glow_band_texture(ctx: &Context) -> TextureHandle {
558        let id = Id::new("egui_map::glow_band_texture");
559        if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
560            return handle;
561        }
562
563        const WIDTH: usize = 64;
564        let pixels = (0..WIDTH)
565            .map(|i| {
566                let u = i as f32 / (WIDTH - 1) as f32;
567                let distance_from_center = (u - 0.5).abs() * 2.0;
568                let alpha = (1.0 - distance_from_center).clamp(0.0, 1.0);
569                let alpha = alpha * alpha * (3.0 - 2.0 * alpha); // smoothstep
570                Color32::from_white_alpha((255.0 * alpha).round() as u8)
571            })
572            .collect();
573        let image = ColorImage::new([WIDTH, 1], pixels);
574        let handle = ctx.load_texture(
575            "egui_map::glow_band",
576            image,
577            TextureOptions {
578                magnification: TextureFilter::Linear,
579                minification: TextureFilter::Linear,
580                wrap_mode: TextureWrapMode::ClampToEdge,
581                mipmap_mode: None,
582            },
583        );
584        ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
585        handle
586    }
587
588    /// One frame of a row of arrow shapes sliding along the segment. `time`
589    /// is the frame time in seconds; the pattern repeats every
590    /// [`CHEVRON_PERIOD_PX`] screen pixels and slides at [`CHEVRON_SPEED`]
591    /// repeats per second.
592    ///
593    /// Reads as *"direction of travel"*, more explicit at a glance than
594    /// [`Animation::comet`]'s single dot. Same mesh-building shape as
595    /// [`Animation::dash`], but where `dash` samples a 1D texture at a
596    /// constant `uv.y = 0.5` (its stripes don't vary across the ribbon's
597    /// width), the two long edges of this mesh get `uv.y = 0.0` / `1.0`
598    /// instead, so the interpolated `uv.y` sweeps across a genuinely 2D
599    /// texture and traces out the arrow shape. A zero-length segment is
600    /// skipped.
601    pub fn chevrons(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
602        let delta = b - a;
603        let len = delta.length();
604        if len <= f32::EPSILON {
605            return;
606        }
607        let dir = delta / len;
608        let normal = Vec2::new(-dir.y, dir.x) * (CHEVRON_WIDTH * zoom * 0.5);
609        // `u0 < u1` (below) maps the texture's own +u direction onto the
610        // segment's `a -> b` direction, and the arrow tip sits at the
611        // texture's higher `u` (see `chevrons_texture`) -- so in any single
612        // frame the tip already reads as pointing towards `b`. The pattern
613        // must then *slide* towards `b` too, not away from it: sampling a
614        // fixed screen point at an ever-larger `u` (`phase` growing with
615        // time) is what would make it crawl towards `a` instead, backwards
616        // from the way the arrows point. Negating the time term here is what
617        // keeps the two in agreement.
618        let phase = (-(time * CHEVRON_SPEED)).rem_euclid(1.0);
619        let u0 = phase;
620        let u1 = phase + len / CHEVRON_PERIOD_PX;
621
622        let texture = Self::chevrons_texture(painter.ctx());
623        let mut mesh = Mesh::with_texture(texture.id());
624        mesh.vertices.extend([
625            Vertex {
626                pos: a + normal,
627                uv: pos2(u0, 0.0),
628                color,
629            },
630            Vertex {
631                pos: a - normal,
632                uv: pos2(u0, 1.0),
633                color,
634            },
635            Vertex {
636                pos: b + normal,
637                uv: pos2(u1, 0.0),
638                color,
639            },
640            Vertex {
641                pos: b - normal,
642                uv: pos2(u1, 1.0),
643                color,
644            },
645        ]);
646        mesh.indices.extend([0, 1, 2, 2, 1, 3]);
647        painter.add(mesh);
648    }
649
650    /// Returns the texture [`Animation::chevrons`] samples, creating and
651    /// caching it in the context's own temp data on first use -- same pattern
652    /// as [`Animation::dash_texture`].
653    ///
654    /// A genuinely 2D tile, unlike `dash`'s 1x32 strip: for each row `v`
655    /// (0 at one long edge of the ribbon, 1 at the other) the arrow's stroke
656    /// sits at an "ideal" `u` that moves back from a tip near the leading
657    /// edge as `v` moves away from the centreline in either direction --
658    /// tracing the two legs of a `>` shape -- and each texel's alpha falls
659    /// off with its distance from that ideal `u`, smoothstepped for a soft
660    /// stroke. [`TextureWrapMode::Repeat`] tiles it along `u`; `v` never
661    /// leaves `[0, 1]` in the mesh above, so wrapping never triggers on that
662    /// axis.
663    fn chevrons_texture(ctx: &Context) -> TextureHandle {
664        let id = Id::new("egui_map::chevrons_texture");
665        if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
666            return handle;
667        }
668
669        const WIDTH: usize = 32;
670        const HEIGHT: usize = 16;
671        const TIP_U: f32 = 0.75;
672        const LEG_SLOPE: f32 = 0.5;
673        const STROKE_THICKNESS: f32 = 0.12;
674
675        let mut pixels = Vec::with_capacity(WIDTH * HEIGHT);
676        for j in 0..HEIGHT {
677            let v = j as f32 / (HEIGHT - 1) as f32;
678            let ideal_u = TIP_U - LEG_SLOPE * (v - 0.5).abs();
679            for i in 0..WIDTH {
680                let u = i as f32 / WIDTH as f32;
681                let distance = (u - ideal_u).abs();
682                let alpha = (1.0 - distance / STROKE_THICKNESS).clamp(0.0, 1.0);
683                let alpha = alpha * alpha * (3.0 - 2.0 * alpha); // smoothstep
684                pixels.push(Color32::from_white_alpha((255.0 * alpha).round() as u8));
685            }
686        }
687        let image = ColorImage::new([WIDTH, HEIGHT], pixels);
688        let handle = ctx.load_texture(
689            "egui_map::chevrons",
690            image,
691            TextureOptions {
692                magnification: TextureFilter::Linear,
693                minification: TextureFilter::Linear,
694                wrap_mode: TextureWrapMode::Repeat,
695                mipmap_mode: None,
696            },
697        );
698        ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
699        handle
700    }
701
702    // ------------------------------------------------------------ persistent
703
704    /// One frame of a ring whose opacity breathes in and out.
705    ///
706    /// For lasting state — *"you are here"*, *"this system is camped"*. `time`
707    /// is the frame time in seconds (`ui.input(|i| i.time)`).
708    ///
709    /// The radius has a floor in screen pixels so the halo does not vanish
710    /// when the map is zoomed far out.
711    pub fn halo(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
712        const PERIOD: f32 = 2.0;
713        let alpha = 0.30 + 0.45 * triangle_wave(time, PERIOD);
714        let radius = (9.0 * zoom).max(5.0);
715        painter.add(Shape::Circle(CircleShape::stroke(
716            center,
717            radius,
718            Stroke::new((2.0 * zoom).max(1.5), with_alpha(color, alpha)),
719        )));
720    }
721
722    /// One frame of a thick ring blinking on and off.
723    ///
724    /// This is the effect markers have always used, factored out of the widget
725    /// so it can be selected and reused like any other. `time` is the frame
726    /// time in seconds.
727    pub fn blink(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
728        const PERIOD: f32 = 2.55;
729        painter.add(Shape::Circle(CircleShape::stroke(
730            center,
731            4.0 * zoom,
732            Stroke::new(9.0 * zoom, with_alpha(color, triangle_wave(time, PERIOD))),
733        )));
734    }
735
736    /// One frame of a dot orbiting the node, with a faint guide ring.
737    ///
738    /// Reads as *"under observation"*. `time` is the frame time in seconds.
739    pub fn orbit(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
740        const PERIOD: f32 = 3.0;
741        let radius = (12.0 * zoom).max(7.0);
742        let angle = TAU * (time / PERIOD).rem_euclid(1.0);
743        let dot = Pos2::new(
744            center.x + radius * angle.cos(),
745            center.y + radius * angle.sin(),
746        );
747        painter.extend([
748            Shape::Circle(CircleShape::stroke(
749                center,
750                radius,
751                Stroke::new(1.0, with_alpha(color, 0.25)),
752            )),
753            Shape::Circle(CircleShape::filled(
754                dot,
755                (2.5 * zoom).max(2.0),
756                with_alpha(color, 1.0),
757            )),
758        ]);
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use egui::{Context, LayerId, Rect, Vec2};
766    use std::time::Duration;
767
768    fn headless_painter() -> Painter {
769        Painter::new(
770            Context::default(),
771            LayerId::background(),
772            Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 100.0)),
773        )
774    }
775
776    /// Every event-driven effect, with the duration it is supposed to run for.
777    #[allow(clippy::type_complexity)]
778    fn event_effects() -> Vec<(
779        &'static str,
780        fn(&Painter, Pos2, f32, Instant, Color32) -> bool,
781        f32,
782    )> {
783        vec![
784            ("pulse", Animation::pulse, PULSE_DURATION),
785            ("ripple", Animation::ripple, RIPPLE_DURATION),
786            (
787                "countdown_arc",
788                Animation::countdown_arc,
789                COUNTDOWN_DURATION,
790            ),
791            ("scale_in", Animation::scale_in, SCALE_IN_DURATION),
792            ("crosshair", Animation::crosshair, CROSSHAIR_DURATION),
793        ]
794    }
795
796    #[test]
797    fn event_effects_report_running_then_finished() {
798        let painter = headless_painter();
799        for (name, effect, duration) in event_effects() {
800            assert!(
801                effect(&painter, Pos2::ZERO, 1.0, Instant::now(), Color32::RED),
802                "{name} must report it is still running when it just started"
803            );
804
805            let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
806            assert!(
807                !effect(&painter, Pos2::ZERO, 1.0, long_past, Color32::RED),
808                "{name} must report it is finished once its duration has passed"
809            );
810        }
811    }
812
813    #[test]
814    fn every_event_effect_stays_under_the_orphan_sweep() {
815        // `Map` drops notifications older than 10s as a safety net. An effect
816        // that outlived it would be cut off mid-play.
817        for (name, _, duration) in event_effects() {
818            assert!(
819                duration < 10.0,
820                "{name} lasts {duration}s, which the 10s orphan sweep would truncate"
821            );
822        }
823    }
824
825    #[test]
826    fn persistent_effects_run_at_any_time() {
827        let painter = headless_painter();
828        for time in [0.0, 0.7, 1.3, 60.0] {
829            Animation::halo(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
830            Animation::blink(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
831            Animation::orbit(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
832        }
833    }
834
835    /// Every segment event-driven effect, with the duration it runs for.
836    #[allow(clippy::type_complexity)]
837    fn segment_event_effects() -> Vec<(
838        &'static str,
839        fn(&Painter, Pos2, Pos2, f32, Instant, Color32) -> bool,
840        f32,
841    )> {
842        vec![
843            ("flash_decay", Animation::flash_decay, FLASH_DECAY_DURATION),
844            ("wipe", Animation::wipe, WIPE_DURATION),
845        ]
846    }
847
848    #[test]
849    fn segment_event_effects_report_running_then_finished() {
850        let painter = headless_painter();
851        let a = Pos2::ZERO;
852        let b = Pos2::new(50.0, 0.0);
853        for (name, effect, duration) in segment_event_effects() {
854            assert!(
855                effect(&painter, a, b, 1.0, Instant::now(), Color32::RED),
856                "{name} must report it is still running when it just started"
857            );
858
859            let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
860            assert!(
861                !effect(&painter, a, b, 1.0, long_past, Color32::RED),
862                "{name} must report it is finished once its duration has passed"
863            );
864        }
865    }
866
867    #[test]
868    fn every_segment_event_effect_stays_under_the_orphan_sweep() {
869        for (name, _, duration) in segment_event_effects() {
870            assert!(
871                duration < 10.0,
872                "{name} lasts {duration}s, which the 10s orphan sweep would truncate"
873            );
874        }
875    }
876
877    #[test]
878    fn comet_runs_at_any_time_and_stays_on_the_segment() {
879        let painter = headless_painter();
880        let a = Pos2::ZERO;
881        let b = Pos2::new(50.0, 0.0);
882        for time in [0.0, 0.4, 0.8, 60.0] {
883            Animation::comet(&painter, a, b, 1.0, time, Color32::GREEN);
884        }
885    }
886
887    #[test]
888    fn comet_loops_back_to_the_start() {
889        // One full `COMET_PERIOD` later it should be back where it began.
890        let t0 = 0.2;
891        let t1 = t0 + COMET_PERIOD;
892        let at = |t: f32| {
893            let frac = (t / COMET_PERIOD).rem_euclid(1.0);
894            Pos2::ZERO + (Pos2::new(50.0, 0.0) - Pos2::ZERO) * frac
895        };
896        assert_eq!(at(t0), at(t1));
897    }
898
899    #[test]
900    fn comet_once_reports_running_then_finished() {
901        let painter = headless_painter();
902        let a = Pos2::ZERO;
903        let b = Pos2::new(50.0, 0.0);
904        assert!(
905            Animation::comet_once(
906                &painter,
907                a,
908                b,
909                1.0,
910                Instant::now(),
911                Color32::RED,
912                CometDirection::Forward,
913            ),
914            "comet_once must report it is still running when it just started"
915        );
916
917        let long_past = Instant::now() - Duration::from_secs_f32(COMET_TRAVEL_DURATION + 1.0);
918        assert!(
919            !Animation::comet_once(
920                &painter,
921                a,
922                b,
923                1.0,
924                long_past,
925                Color32::RED,
926                CometDirection::Forward,
927            ),
928            "comet_once must report it is finished once its duration has passed"
929        );
930    }
931
932    #[test]
933    // Comparing two `const`s is deliberate here: this is a guard against a
934    // future edit to `COMET_TRAVEL_DURATION`, not a runtime check.
935    #[allow(clippy::assertions_on_constants)]
936    fn comet_once_stays_under_the_orphan_sweep() {
937        assert!(
938            COMET_TRAVEL_DURATION < 10.0,
939            "comet_once lasts {COMET_TRAVEL_DURATION}s, which the 10s orphan sweep would truncate"
940        );
941    }
942
943    #[test]
944    fn comet_once_direction_picks_the_starting_endpoint() {
945        // At the very start of the animation the dot must sit on the
946        // starting endpoint -- `a` for `Forward`, `b` for `Reverse` -- not
947        // partway along the segment.
948        let a = Pos2::ZERO;
949        let b = Pos2::new(50.0, 0.0);
950        let start_pos = |direction: CometDirection| {
951            let secs = 0.0_f32;
952            let progress = (secs / COMET_TRAVEL_DURATION).clamp(0.0, 1.0);
953            let (from, to) = match direction {
954                CometDirection::Forward => (a, b),
955                CometDirection::Reverse => (b, a),
956            };
957            from + (to - from) * progress
958        };
959        assert_eq!(start_pos(CometDirection::Forward), a);
960        assert_eq!(start_pos(CometDirection::Reverse), b);
961    }
962
963    #[test]
964    fn wipe_progress_interpolates_toward_the_far_endpoint() {
965        // Same reasoning as `comet_once_direction_picks_the_starting_endpoint`:
966        // the widget centers and offsets the rendered view, so comparing a
967        // rendered position against raw map-space coordinates would be
968        // fragile. `wipe`'s leading edge is a plain `lerp(a, b, progress)`,
969        // so this checks the formula directly instead of rendering a frame.
970        let a = Pos2::ZERO;
971        let b = Pos2::new(50.0, 0.0);
972        let leading_edge = |progress: f32| a + (b - a) * progress;
973
974        assert_eq!(leading_edge(0.0), a, "must start exactly at `a`");
975        assert_eq!(leading_edge(1.0), b, "must finish exactly at `b`");
976        assert_eq!(leading_edge(0.5), Pos2::new(25.0, 0.0));
977    }
978
979    #[test]
980    fn dash_runs_at_any_time_and_skips_zero_length_segments() {
981        let painter = headless_painter();
982        let a = Pos2::ZERO;
983        let b = Pos2::new(50.0, 0.0);
984        for time in [0.0, 0.4, 0.8, 60.0] {
985            Animation::dash(&painter, a, b, 1.0, time, Color32::GREEN);
986        }
987        // A degenerate (zero-length) segment must not panic -- the
988        // direction/normal math divides by the segment's length.
989        Animation::dash(&painter, a, a, 1.0, 0.0, Color32::GREEN);
990    }
991
992    #[test]
993    fn dash_texture_is_registered_once_per_context() {
994        // Repeated calls on the same `Context` must reuse the same texture
995        // rather than re-uploading one every frame.
996        let ctx = Context::default();
997        let first = Animation::dash_texture(&ctx);
998        let second = Animation::dash_texture(&ctx);
999        assert_eq!(first.id(), second.id());
1000    }
1001
1002    #[test]
1003    fn glow_band_runs_at_any_time_and_skips_zero_length_segments() {
1004        let painter = headless_painter();
1005        let a = Pos2::ZERO;
1006        let b = Pos2::new(50.0, 0.0);
1007        for time in [0.0, 0.4, 0.8, 60.0] {
1008            Animation::glow_band(&painter, a, b, 1.0, time, Color32::GREEN);
1009        }
1010        // A degenerate (zero-length) segment must not panic -- the
1011        // direction/normal math divides by the segment's length.
1012        Animation::glow_band(&painter, a, a, 1.0, 0.0, Color32::GREEN);
1013    }
1014
1015    #[test]
1016    fn glow_band_texture_is_registered_once_per_context() {
1017        let ctx = Context::default();
1018        let first = Animation::glow_band_texture(&ctx);
1019        let second = Animation::glow_band_texture(&ctx);
1020        assert_eq!(first.id(), second.id());
1021    }
1022
1023    #[test]
1024    fn chevrons_runs_at_any_time_and_skips_zero_length_segments() {
1025        let painter = headless_painter();
1026        let a = Pos2::ZERO;
1027        let b = Pos2::new(50.0, 0.0);
1028        for time in [0.0, 0.4, 0.8, 60.0] {
1029            Animation::chevrons(&painter, a, b, 1.0, time, Color32::GREEN);
1030        }
1031        Animation::chevrons(&painter, a, a, 1.0, 0.0, Color32::GREEN);
1032    }
1033
1034    #[test]
1035    fn chevrons_texture_is_registered_once_per_context() {
1036        let ctx = Context::default();
1037        let first = Animation::chevrons_texture(&ctx);
1038        let second = Animation::chevrons_texture(&ctx);
1039        assert_eq!(first.id(), second.id());
1040    }
1041
1042    #[test]
1043    fn triangle_wave_goes_up_and_back_down() {
1044        assert_eq!(triangle_wave(0.0, 2.0), 0.0);
1045        assert_eq!(triangle_wave(1.0, 2.0), 1.0);
1046        assert!(triangle_wave(2.0, 2.0).abs() < 1e-6);
1047        assert!((triangle_wave(3.0, 2.0) - 1.0).abs() < 1e-6);
1048        for step in 0..200 {
1049            let v = triangle_wave(step as f32 * 0.05, 2.55);
1050            assert!((0.0..=1.0).contains(&v), "{v} out of range");
1051        }
1052    }
1053
1054    #[test]
1055    fn ease_out_back_overshoots_then_settles() {
1056        assert_eq!(ease_out_back(0.0), 0.0);
1057        assert!((ease_out_back(1.0) - 1.0).abs() < 1e-5);
1058        let peak = (0..=100)
1059            .map(|i| ease_out_back(i as f32 / 100.0))
1060            .fold(f32::MIN, f32::max);
1061        assert!(
1062            peak > 1.0,
1063            "ease_out_back should overshoot, peaked at {peak}"
1064        );
1065    }
1066
1067    #[test]
1068    fn with_alpha_clamps_out_of_range_values() {
1069        assert_eq!(with_alpha(Color32::RED, 2.0).a(), 255);
1070        assert_eq!(with_alpha(Color32::RED, -1.0).a(), 0);
1071        assert_eq!(with_alpha(Color32::RED, 1.0), Color32::RED);
1072    }
1073}