Skip to main content

world_map/
world_map.rs

1use motion_canvas_rs::prelude::*;
2use std::{ops::Add, time::Duration};
3
4/// Country landmark data: (name, x, y) on a 1010x666 map (SVG)
5struct Landmark {
6    name: &'static str,
7    x: f32,
8    y: f32,
9}
10
11/// Map native dimensions (from SVG viewBox: 1009.7 x 666)
12const MAP_W: f32 = 800.0;
13const MAP_H: f32 = 600.0;
14
15/// Coordinates on the 800x600 map grid.
16const LANDMARKS: [Landmark; 22] = [
17    Landmark {
18        name: "Iceland",
19        x: 337.0,
20        y: 215.0,
21    },
22    Landmark {
23        name: "Norway",
24        x: 397.0,
25        y: 235.0,
26    },
27    Landmark {
28        name: "Sweden",
29        x: 412.0,
30        y: 225.0,
31    },
32    Landmark {
33        name: "Finland",
34        x: 437.0,
35        y: 220.0,
36    },
37    Landmark {
38        name: "Denmark",
39        x: 399.0,
40        y: 254.0,
41    },
42    Landmark {
43        name: "Netherlands",
44        x: 390.0,
45        y: 268.0,
46    },
47    Landmark {
48        name: "Luxembourg",
49        x: 392.0,
50        y: 278.0,
51    },
52    Landmark {
53        name: "Germany",
54        x: 400.0,
55        y: 270.0,
56    },
57    Landmark {
58        name: "Switzerland",
59        x: 396.0,
60        y: 286.0,
61    },
62    Landmark {
63        name: "Egypt",
64        x: 442.0,
65        y: 340.0,
66    },
67    Landmark {
68        name: "Russia",
69        x: 542.0,
70        y: 220.0,
71    },
72    Landmark {
73        name: "China",
74        x: 602.0,
75        y: 320.0,
76    },
77    Landmark {
78        name: "Singapore",
79        x: 600.0,
80        y: 395.0,
81    },
82    Landmark {
83        name: "Japan",
84        x: 677.0,
85        y: 315.0,
86    },
87    Landmark {
88        name: "Australia",
89        x: 665.0,
90        y: 455.0,
91    },
92    Landmark {
93        name: "New Zealand",
94        x: 749.0,
95        y: 499.0,
96    },
97    Landmark {
98        name: "Brazil",
99        x: 272.0,
100        y: 425.0,
101    },
102    Landmark {
103        name: "Paraguay",
104        x: 254.0,
105        y: 452.0,
106    },
107    Landmark {
108        name: "El Salvador",
109        x: 189.0,
110        y: 371.0,
111    },
112    Landmark {
113        name: "Mexico",
114        x: 157.0,
115        y: 345.0,
116    },
117    Landmark {
118        name: "USA",
119        x: 177.0,
120        y: 310.0,
121    },
122    Landmark {
123        name: "Canada",
124        x: 167.0,
125        y: 240.0,
126    },
127];
128
129/// Helper: duration that starts slow and speeds up
130fn tour_duration(index: usize) -> Duration {
131    // First leg: 4.5s, last leg: 1.2s. Linear interpolation.
132    let t = index as f32 / (LANDMARKS.len() - 1) as f32;
133    let secs = 4.0 * (1.1 - t) + 1.2 * t;
134    Duration::from_secs_f32(secs)
135}
136
137/// Sky blue background
138const SKY_BG: Color = Color::rgb8(0xcf, 0xe5, 0xe8);
139
140/// Center of map in world coords
141const MAP_CX: f32 = MAP_W / 2.0;
142const MAP_CY: f32 = MAP_H / 2.0;
143
144/// Cloud definition helper
145struct CloudDef {
146    x: f32,
147    y: f32,
148    w: f32,
149    h: f32,
150    img: &'static str,
151    flip_x: bool,
152    opacity: f32,
153    /// Exit direction (x, y) when clearing
154    exit_x: f32,
155    exit_y: f32,
156}
157
158const CLOUDS: [CloudDef; 24] = [
159    // Layer 1: Large foreground clouds (dense cover)
160    CloudDef {
161        x: 150.0,
162        y: 180.0,
163        w: 400.0,
164        h: 200.0,
165        img: "cloud-1.png",
166        flip_x: false,
167        opacity: 0.95,
168        exit_x: -500.0,
169        exit_y: 100.0,
170    },
171    CloudDef {
172        x: 800.0,
173        y: 140.0,
174        w: 380.0,
175        h: 180.0,
176        img: "cloud-2.png",
177        flip_x: false,
178        opacity: 0.92,
179        exit_x: 1600.0,
180        exit_y: 50.0,
181    },
182    CloudDef {
183        x: 500.0,
184        y: 480.0,
185        w: 450.0,
186        h: 210.0,
187        img: "cloud-3.png",
188        flip_x: false,
189        opacity: 0.90,
190        exit_x: 500.0,
191        exit_y: 900.0,
192    },
193    CloudDef {
194        x: 920.0,
195        y: 350.0,
196        w: 350.0,
197        h: 160.0,
198        img: "cloud-1.png",
199        flip_x: true,
200        opacity: 0.88,
201        exit_x: 1500.0,
202        exit_y: 450.0,
203    },
204    CloudDef {
205        x: 400.0,
206        y: 320.0,
207        w: 420.0,
208        h: 200.0,
209        img: "cloud-2.png",
210        flip_x: true,
211        opacity: 0.93,
212        exit_x: -500.0,
213        exit_y: 400.0,
214    },
215    CloudDef {
216        x: 700.0,
217        y: 500.0,
218        w: 400.0,
219        h: 190.0,
220        img: "cloud-3.png",
221        flip_x: false,
222        opacity: 0.91,
223        exit_x: 1400.0,
224        exit_y: 800.0,
225    },
226    // Layer 2: Medium clouds
227    CloudDef {
228        x: 50.0,
229        y: 400.0,
230        w: 350.0,
231        h: 170.0,
232        img: "cloud-2.png",
233        flip_x: true,
234        opacity: 0.82,
235        exit_x: -450.0,
236        exit_y: 500.0,
237    },
238    CloudDef {
239        x: 350.0,
240        y: 90.0,
241        w: 320.0,
242        h: 150.0,
243        img: "cloud-3.png",
244        flip_x: true,
245        opacity: 0.80,
246        exit_x: 350.0,
247        exit_y: -300.0,
248    },
249    CloudDef {
250        x: 700.0,
251        y: 300.0,
252        w: 300.0,
253        h: 140.0,
254        img: "cloud-1.png",
255        flip_x: false,
256        opacity: 0.78,
257        exit_x: 1400.0,
258        exit_y: 300.0,
259    },
260    CloudDef {
261        x: 200.0,
262        y: 550.0,
263        w: 380.0,
264        h: 180.0,
265        img: "cloud-2.png",
266        flip_x: false,
267        opacity: 0.76,
268        exit_x: -400.0,
269        exit_y: 750.0,
270    },
271    CloudDef {
272        x: 950.0,
273        y: 100.0,
274        w: 330.0,
275        h: 155.0,
276        img: "cloud-1.png",
277        flip_x: true,
278        opacity: 0.79,
279        exit_x: 1500.0,
280        exit_y: -200.0,
281    },
282    CloudDef {
283        x: 100.0,
284        y: 100.0,
285        w: 340.0,
286        h: 160.0,
287        img: "cloud-3.png",
288        flip_x: false,
289        opacity: 0.77,
290        exit_x: -400.0,
291        exit_y: -200.0,
292    },
293    // Layer 3: Small wisps filling gaps
294    CloudDef {
295        x: 600.0,
296        y: 200.0,
297        w: 250.0,
298        h: 120.0,
299        img: "cloud-3.png",
300        flip_x: false,
301        opacity: 0.72,
302        exit_x: 1300.0,
303        exit_y: 100.0,
304    },
305    CloudDef {
306        x: 100.0,
307        y: 280.0,
308        w: 280.0,
309        h: 130.0,
310        img: "cloud-1.png",
311        flip_x: true,
312        opacity: 0.70,
313        exit_x: -380.0,
314        exit_y: 280.0,
315    },
316    CloudDef {
317        x: 450.0,
318        y: 350.0,
319        w: 260.0,
320        h: 120.0,
321        img: "cloud-2.png",
322        flip_x: true,
323        opacity: 0.68,
324        exit_x: 450.0,
325        exit_y: 800.0,
326    },
327    CloudDef {
328        x: 850.0,
329        y: 500.0,
330        w: 300.0,
331        h: 140.0,
332        img: "cloud-3.png",
333        flip_x: true,
334        opacity: 0.65,
335        exit_x: 1400.0,
336        exit_y: 700.0,
337    },
338    CloudDef {
339        x: 250.0,
340        y: 450.0,
341        w: 270.0,
342        h: 125.0,
343        img: "cloud-1.png",
344        flip_x: false,
345        opacity: 0.67,
346        exit_x: -350.0,
347        exit_y: 600.0,
348    },
349    CloudDef {
350        x: 550.0,
351        y: 100.0,
352        w: 290.0,
353        h: 135.0,
354        img: "cloud-2.png",
355        flip_x: true,
356        opacity: 0.69,
357        exit_x: 550.0,
358        exit_y: -300.0,
359    },
360    // Layer 4: Tiny accent clouds (extra cover)
361    CloudDef {
362        x: 300.0,
363        y: 150.0,
364        w: 200.0,
365        h: 100.0,
366        img: "cloud-1.png",
367        flip_x: false,
368        opacity: 0.60,
369        exit_x: -300.0,
370        exit_y: 50.0,
371    },
372    CloudDef {
373        x: 750.0,
374        y: 450.0,
375        w: 220.0,
376        h: 100.0,
377        img: "cloud-2.png",
378        flip_x: false,
379        opacity: 0.58,
380        exit_x: 1300.0,
381        exit_y: 550.0,
382    },
383    CloudDef {
384        x: 550.0,
385        y: 550.0,
386        w: 240.0,
387        h: 110.0,
388        img: "cloud-3.png",
389        flip_x: true,
390        opacity: 0.55,
391        exit_x: 550.0,
392        exit_y: 850.0,
393    },
394    CloudDef {
395        x: 950.0,
396        y: 200.0,
397        w: 200.0,
398        h: 90.0,
399        img: "cloud-1.png",
400        flip_x: true,
401        opacity: 0.52,
402        exit_x: 1500.0,
403        exit_y: 100.0,
404    },
405    CloudDef {
406        x: 50.0,
407        y: 550.0,
408        w: 230.0,
409        h: 105.0,
410        img: "cloud-3.png",
411        flip_x: false,
412        opacity: 0.56,
413        exit_x: -350.0,
414        exit_y: 800.0,
415    },
416    CloudDef {
417        x: 830.0,
418        y: 250.0,
419        w: 210.0,
420        h: 95.0,
421        img: "cloud-2.png",
422        flip_x: true,
423        opacity: 0.54,
424        exit_x: 1400.0,
425        exit_y: 150.0,
426    },
427];
428
429fn main() {
430    let w = 800u32;
431    let h = 600u32;
432
433    let mut project = Project::new(w, h)
434        .with_fps(60)
435        .with_title("World Map")
436        .with_background(SKY_BG)
437        .close_on_finish();
438
439    // ── Map (rendered at native SVG size for crispness) ──
440    let map = SvgNode::default()
441        .with_position(Vec2::new(MAP_CX, MAP_CY))
442        .with_path("./examples/images/world.svg")
443        .with_size(Vec2::new(MAP_W, MAP_H))
444        .with_opacity(0.0);
445
446    // ── Clouds — 16 total from 3 source PNGs with variants ──
447    let mut cloud_nodes: Vec<ImageNode> = Vec::new();
448    for def in &CLOUDS {
449        let mut node = ImageNode::default()
450            .with_position(Vec2::new(def.x, def.y))
451            .with_path(&format!("./examples/images/{}", def.img))
452            .with_size(Vec2::new(def.w, def.h))
453            .with_opacity(def.opacity);
454        if def.flip_x {
455            node = node.with_scale_xy(Vec2::new(-1.0, 1.0));
456        }
457        cloud_nodes.push(node);
458    }
459
460    // ── Camera ──
461    let camera = CameraNode::default()
462        .with_size(Vec2::new(w as f32, h as f32))
463        .with_position(Vec2::new(MAP_CX, MAP_CY))
464        .with_zoom(1.0)
465        .with_centered(true);
466
467    // ── Plane ──
468    let plane = SvgNode::default()
469        .with_position(Vec2::new(LANDMARKS[0].x, LANDMARKS[0].y))
470        .with_path("./examples/images/plane.svg")
471        .with_size(Vec2::new(12.0, 12.0))
472        .with_anchor(Vec2::new(2.0, -2.65))
473        .with_scale(0.0)
474        .with_opacity(0.0);
475
476    // ── Landmark pins + name labels ──
477    let mut pins: Vec<Circle> = Vec::new();
478    let mut name_labels: Vec<TextNode> = Vec::new();
479    for lm in &LANDMARKS {
480        let pin = Circle::default()
481            .with_position(Vec2::new(lm.x, lm.y))
482            .with_radius(0.0)
483            .with_fill(Palette::RED)
484            .with_stroke(Color::rgba8(0xff, 0xff, 0xff, 180), 2.0)
485            .with_opacity(0.0);
486        pins.push(pin);
487
488        // Country name below pin
489        let name_lbl = TextNode::default()
490            .with_position(Vec2::new(lm.x, lm.y + 12.0))
491            .with_text(lm.name)
492            .with_font_size(10.0)
493            .with_fill(Palette::DARK_GRAY)
494            .with_opacity(0.0);
495        name_labels.push(name_lbl);
496    }
497
498    // ── Route lines (straight between consecutive landmarks) ──
499    let mut route_lines: Vec<Line> = Vec::new();
500    for i in 0..LANDMARKS.len() - 1 {
501        let from = &LANDMARKS[i];
502        let line = Line::default()
503            .with_start(Vec2::new(from.x, from.y))
504            .with_end(Vec2::new(from.x, from.y))
505            .with_stroke(Color::rgba8(0xff, 0xff, 0xff, 180), 2.0)
506            .with_opacity(0.0);
507        route_lines.push(line);
508    }
509
510    // ── Build Scene Tree ──
511    // Order: map → route lines → plane → pins → labels (pins on top of lines)
512    let mut camera_children: Vec<Box<dyn Node>> = Vec::new();
513    camera_children.push(Box::new(map.clone()));
514    // Route lines first (behind pins)
515    for rl in &route_lines {
516        camera_children.push(Box::new(rl.clone()));
517    }
518    // Plane between routes and pins
519    camera_children.push(Box::new(plane.clone()));
520    // Pins on top of route lines
521    for pin in &pins {
522        camera_children.push(Box::new(pin.clone()));
523    }
524
525    for nl in &name_labels {
526        camera_children.push(Box::new(nl.clone()));
527    }
528
529    // Clouds on top of everything (so they cover the map initially)
530    for cn in &cloud_nodes {
531        camera_children.push(Box::new(cn.clone()));
532    }
533
534    let camera = camera.with_nodes(camera_children);
535    project.scene.add(&camera);
536
537    // ═══════════════════════════════════════════════════
538    //  ANIMATION TIMELINE
539    // ═══════════════════════════════════════════════════
540
541    // Phase 1: Intro — clouds everywhere, then zoom in / scatter clouds / reveal map
542    let mut phase1_anims: Vec<AnyAnimation> = Vec::new();
543
544    // Camera zoom
545    phase1_anims.push(
546        camera
547            .zoom
548            .to(2.25, Duration::from_secs(3))
549            .ease(easings::cubic_in_out)
550            .into(),
551    );
552    // Map fade in
553    phase1_anims.push(
554        map.opacity
555            .to(1.0, Duration::from_secs(2))
556            .ease(easings::cubic_out)
557            .into(),
558    );
559
560    // Scatter all clouds
561    for (i, cn) in cloud_nodes.iter().enumerate() {
562        let def = &CLOUDS[i];
563        phase1_anims.push(
564            cn.position
565                .to(Vec2::new(def.exit_x, def.exit_y), Duration::from_secs(3))
566                .ease(easings::cubic_in)
567                .into(),
568        );
569        phase1_anims.push(
570            cn.opacity
571                .to(0.0, Duration::from_secs(2))
572                .ease(easings::cubic_in)
573                .into(),
574        );
575    }
576
577    let phase1_intro: AnyAnimation = chain![
578        // Everything happens at once: zoom, clouds scatter, map reveals, title appears
579        all(phase1_anims),
580        wait(Duration::from_millis(500)),
581    ];
582
583    // Phase 2: Pan camera to first landmark and show plane
584    let first = &LANDMARKS[0];
585    let phase2_start: AnyAnimation = chain![
586        camera
587            .position
588            .to(Vec2::new(first.x, first.y), Duration::from_secs(2))
589            .ease(easings::cubic_in_out),
590        camera
591            .zoom
592            .to(5.0, Duration::from_secs(1))
593            .ease(easings::cubic_out),
594        // Show first pin + labels
595        all![
596            pins[0].opacity.to(1.0, Duration::from_millis(400)),
597            pins[0]
598                .radius
599                .to(3.0, Duration::from_millis(600))
600                .ease(easings::elastic_out),
601            name_labels[0].opacity.to(1.0, Duration::from_millis(400)),
602        ],
603        // Show plane
604        plane.opacity.to(1.0, Duration::from_millis(300)),
605        wait(Duration::from_millis(800)),
606    ];
607
608    // Phase 3: Tour through each country (scale in -> move -> scale out -> show landmark)
609    let mut tour_legs: Vec<AnyAnimation> = Vec::new();
610    for i in 0..LANDMARKS.len() - 1 {
611        let from = &LANDMARKS[i];
612        let to = &LANDMARKS[i + 1];
613        let dur = tour_duration(i);
614
615        // Compute heading angle from straight line
616        let dx = to.x - from.x;
617        let dy = to.y - from.y;
618        let angle = dy.atan2(dx) + (std::f32::consts::PI);
619
620        let leg: AnyAnimation = chain![
621            // 1. Scale plane in at the start of the leg
622            all![
623                plane
624                    .rotation
625                    .to(angle, Duration::from_millis(200))
626                    .ease(easings::cubic_out),
627                plane
628                    .scale
629                    .to(Vec2::splat(1.0), Duration::from_millis(300))
630                    .ease(easings::cubic_out),
631                // Hide current landmark name as we depart
632                name_labels[i]
633                    .opacity
634                    .to(0.0, Duration::from_millis(200))
635                    .ease(easings::cubic_out),
636            ],
637            // 2. Move plane to destination
638            all![
639                // Show route line
640                route_lines[i].opacity.to(1.0, Duration::from_millis(100)),
641                // Draw line to destination
642                route_lines[i]
643                    .end
644                    .to(Vec2::new(to.x, to.y), dur)
645                    .ease(easings::cubic_in_out),
646                all![
647                    // Move plane to destination
648                    plane
649                        .position
650                        .to(Vec2::new(to.x, to.y), dur)
651                        .ease(easings::cubic_in_out),
652                    // Camera follows to destination
653                    camera
654                        .position
655                        .to(Vec2::new(to.x, to.y), dur.add(Duration::from_millis(500)))
656                        .ease(easings::cubic_in_out),
657                ]
658            ],
659            // 3. Scale plane out at the destination
660            plane
661                .scale
662                .to(Vec2::splat(0.0), Duration::from_millis(300))
663                .ease(easings::cubic_in),
664            // 4. Reveal destination pin + name
665            all![
666                pins[i + 1].opacity.to(1.0, Duration::from_millis(300)),
667                pins[i + 1]
668                    .radius
669                    .to(3.0, Duration::from_millis(500))
670                    .ease(easings::elastic_out),
671                name_labels[i + 1]
672                    .opacity
673                    .to(1.0, Duration::from_millis(300)),
674            ],
675            // 5. Wait a bit for viewer to read
676            wait(Duration::from_millis(600)),
677            // 6. Name disappears before plane scales in for next leg
678            name_labels[i + 1]
679                .opacity
680                .to(0.0, Duration::from_millis(200))
681                .ease(easings::cubic_out),
682        ];
683        tour_legs.push(leg);
684    }
685
686    let phase3_tour = chain(tour_legs);
687
688    // Phase 4: Zoom out to show full map
689    let phase4_finale: AnyAnimation = chain![
690        // Hide the last landmark's name
691        name_labels[LANDMARKS.len() - 1]
692            .opacity
693            .to(0.0, Duration::from_millis(200)),
694        wait(Duration::from_millis(300)),
695        all![
696            camera
697                .position
698                .to(Vec2::new(MAP_CX, MAP_CY), Duration::from_secs(3))
699                .ease(easings::cubic_in_out),
700            camera
701                .zoom
702                .to(1.0, Duration::from_secs(3))
703                .ease(easings::sine_in_out),
704        ],
705        // Pulse all pins (dynamic loop)
706        {
707            let mut pulse_anims: Vec<AnyAnimation> = Vec::new();
708            for pin in &pins {
709                pulse_anims.push(
710                    pin.radius
711                        .to(10.0, Duration::from_millis(300))
712                        .ease(easings::elastic_out)
713                        .into(),
714                );
715            }
716            sequence(Duration::from_millis(60), pulse_anims)
717        },
718        plane.opacity.to(0.0, Duration::from_millis(500)),
719        wait(Duration::from_secs(3)),
720    ];
721
722    project.scene.video_timeline.add(chain![
723        phase1_intro,
724        phase2_start,
725        phase3_tour,
726        phase4_finale,
727    ]);
728
729    project.show().expect("Failed to render");
730}