Skip to main content

rustmotion_components/
dot_map.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, Rect};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
10    subpixel_font, typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15const LIMB_VISIBILITY_EPSILON: f64 = 0.0;
16const ARC_STEPS: usize = 96;
17const ARC_STROKE_WIDTH: f32 = 2.5;
18const ARC_DEFAULT_ALTITUDE: f32 = 0.12;
19const ARC_DEFAULT_COLOR: &str = "#F97316";
20const ORTHOGRAPHIC_DISC_MARGIN: f32 = 0.94;
21
22fn default_arc_draw_in() -> f32 {
23    1.0
24}
25
26/// `dot_map.projection`. `Equirectangular` is the historical flat mapping
27/// (`lng`/`lat` scaled linearly onto the box); `Orthographic` renders the map
28/// as a globe seen from outside, with the far hemisphere culled and dots
29/// converging towards the limb — a `clip-path: circle` on the flat map only
30/// cuts a disc out of it, it never curves.
31#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case")]
33pub enum DotMapProjection {
34    #[default]
35    Equirectangular,
36    Orthographic,
37}
38
39/// A `rotate.lng` value: either a fixed longitude, or a linear sweep from
40/// `from` to `to` starting at the component's own `start_at`, over
41/// `duration` seconds (defaults to `animation_duration`).
42#[derive(Debug, Serialize, Deserialize, JsonSchema)]
43#[serde(untagged)]
44pub enum RotationAngle {
45    Fixed(f32),
46    Animated {
47        from: f32,
48        to: f32,
49        #[serde(default)]
50        duration: Option<f64>,
51    },
52}
53
54impl RotationAngle {
55    fn value_at(&self, time: f64, start_at: f64, fallback_duration: f64) -> f32 {
56        match self {
57            RotationAngle::Fixed(v) => *v,
58            RotationAngle::Animated { from, to, duration } => {
59                let dur = duration.unwrap_or(fallback_duration).max(0.0001);
60                let elapsed = (time - start_at).max(0.0);
61                let p = (elapsed / dur).clamp(0.0, 1.0) as f32;
62                from + (to - from) * p
63            }
64        }
65    }
66}
67
68/// `dot_map.rotate` in `orthographic` mode: `lng` is rotation about the
69/// pole (the sub-observer meridian), `lat` tilts which parallel faces the
70/// camera. Both default to 0 (looking at `(0, 0)`).
71#[derive(Debug, Serialize, Deserialize, JsonSchema)]
72pub struct GlobeRotation {
73    #[serde(default)]
74    pub lng: Option<RotationAngle>,
75    #[serde(default)]
76    pub lat: Option<f32>,
77}
78
79/// A great-circle arc lifted off the globe's surface, drawn between two
80/// `[lat, lng]` endpoints. `draw_in` reveals the arc from `from` towards
81/// `to` (1.0 = fully drawn) as a per-arc ceiling: the fraction actually
82/// painted this frame is `draw_in` scaled by the node's own animated
83/// `draw_progress` when one is active (the same property `line`/`arrow`
84/// read), so a `draw_progress` keyframe on the `dot_map` component now
85/// sweeps every arc in over time instead of being silently inert. With no
86/// `draw_progress` animation, `draw_in` alone still applies as a fixed
87/// fraction.
88#[derive(Debug, Serialize, Deserialize, JsonSchema)]
89pub struct GreatCircleArc {
90    pub from: [f64; 2],
91    pub to: [f64; 2],
92    #[serde(default = "default_arc_draw_in")]
93    pub draw_in: f32,
94    #[serde(default)]
95    pub color: Option<String>,
96    /// How far the arc lifts off the surface at its midpoint, as a fraction
97    /// of the globe's radius. Default 0.12.
98    #[serde(default)]
99    pub altitude: Option<f32>,
100}
101
102fn latlng_to_unit(lat_deg: f64, lng_deg: f64) -> (f64, f64, f64) {
103    let lat = lat_deg.to_radians();
104    let lng = lng_deg.to_radians();
105    (lat.cos() * lng.cos(), lat.cos() * lng.sin(), lat.sin())
106}
107
108fn unit_to_latlng(v: (f64, f64, f64)) -> (f64, f64) {
109    let lat = v.2.clamp(-1.0, 1.0).asin().to_degrees();
110    let lng = v.1.atan2(v.0).to_degrees();
111    (lat, lng)
112}
113
114fn slerp_unit(a: (f64, f64, f64), b: (f64, f64, f64), t: f64) -> (f64, f64, f64) {
115    let dot = (a.0 * b.0 + a.1 * b.1 + a.2 * b.2).clamp(-1.0, 1.0);
116    let theta = dot.acos();
117    if theta.abs() < 1e-9 {
118        return a;
119    }
120    let sin_theta = theta.sin();
121    let wa = ((1.0 - t) * theta).sin() / sin_theta;
122    let wb = (t * theta).sin() / sin_theta;
123    (
124        a.0 * wa + b.0 * wb,
125        a.1 * wa + b.1 * wb,
126        a.2 * wa + b.2 * wb,
127    )
128}
129
130/// The closed-form orthographic projection: `(lat, lng)` centred on
131/// `(center_lat, center_lng)`, returned as normalized `(x, y)` on a unit
132/// sphere (multiply by the disc's pixel radius) plus `cos_c`, the cosine of
133/// the angular distance from the sub-observer point — `1.0` at the centre
134/// of the disc, `0.0` at the limb, negative on the far hemisphere. `None`
135/// means the point is on the far hemisphere and must not be drawn.
136fn orthographic_project(
137    lat_deg: f64,
138    lng_deg: f64,
139    center_lat_deg: f64,
140    center_lng_deg: f64,
141) -> Option<(f64, f64, f64)> {
142    let phi = lat_deg.to_radians();
143    let lambda = lng_deg.to_radians();
144    let phi1 = center_lat_deg.to_radians();
145    let lambda0 = center_lng_deg.to_radians();
146    let dlambda = lambda - lambda0;
147    let cos_c = phi1.sin() * phi.sin() + phi1.cos() * phi.cos() * dlambda.cos();
148    if cos_c < LIMB_VISIBILITY_EPSILON {
149        return None;
150    }
151    let x = phi.cos() * dlambda.sin();
152    let y = phi1.cos() * phi.sin() - phi1.sin() * phi.cos() * dlambda.cos();
153    Some((x, y, cos_c))
154}
155
156fn limb_shade(limb_shading: Option<f32>, cos_c: f64) -> f32 {
157    let strength = limb_shading.unwrap_or(0.0).clamp(0.0, 1.0);
158    (1.0 - strength * (1.0 - cos_c as f32)).clamp(0.0, 1.0)
159}
160
161fn default_background_color() -> String {
162    "#0F172A".to_string()
163}
164
165fn default_dot_color() -> String {
166    "#334155".to_string()
167}
168
169fn default_dot_spacing() -> f32 {
170    8.0
171}
172
173fn default_dot_radius() -> f32 {
174    1.5
175}
176
177fn default_animated() -> bool {
178    true
179}
180
181fn default_animation_duration() -> f64 {
182    1.5
183}
184
185fn default_show_world() -> bool {
186    true
187}
188
189#[derive(Debug, Serialize, Deserialize, JsonSchema)]
190pub struct MapPoint {
191    /// Latitude (-90 to 90, positive = north)
192    pub lat: f64,
193    /// Longitude (-180 to 180, positive = east)
194    pub lng: f64,
195    #[serde(default)]
196    pub label: Option<String>,
197    #[serde(default)]
198    pub size: Option<f32>,
199    #[serde(default)]
200    pub color: Option<String>,
201    #[serde(default)]
202    pub pulse: Option<bool>,
203}
204
205const LAT_MAX: f64 = 85.0;
206const LAT_MIN: f64 = -85.0;
207
208fn geo_to_screen(lat: f64, lng: f64) -> (f32, f32) {
209    let x = ((lng + 180.0) / 360.0) as f32;
210    let y = ((LAT_MAX - lat) / (LAT_MAX - LAT_MIN)) as f32;
211    (x, y)
212}
213
214fn screen_to_geo(nx: f32, ny: f32) -> (f64, f64) {
215    let lng = nx as f64 * 360.0 - 180.0;
216    let lat = LAT_MAX - ny as f64 * (LAT_MAX - LAT_MIN);
217    (lat, lng)
218}
219
220fn geo_is_land(lat: f64, lng: f64) -> bool {
221    let bitmap = super::world_bitmap::land_bitmap();
222    let col = ((lng + 180.0) / 360.0 * 180.0) as usize;
223    let row = ((90.0 - lat) / 180.0 * 90.0) as usize;
224    if col >= 180 || row >= 90 {
225        return false;
226    }
227    bitmap[row * 180 + col] == 1
228}
229
230#[derive(Debug, Serialize, Deserialize, JsonSchema)]
231pub struct DotMap {
232    pub points: Vec<MapPoint>,
233    #[serde(default = "default_background_color")]
234    pub background_color: String,
235    /// Color of the world map dots
236    #[serde(default = "default_dot_color")]
237    pub world_dot_color: String,
238    /// Spacing between world map dots in pixels
239    #[serde(default = "default_dot_spacing")]
240    pub dot_spacing: f32,
241    /// Radius of each world map dot
242    #[serde(default = "default_dot_radius")]
243    pub dot_radius: f32,
244    /// Show world map dot pattern
245    #[serde(default = "default_show_world")]
246    pub show_world: bool,
247    #[serde(default = "default_animated")]
248    pub animated: bool,
249    #[serde(default = "default_animation_duration")]
250    pub animation_duration: f64,
251    /// `equirectangular` (default, flat — unchanged) or `orthographic` (a
252    /// globe seen from outside: closed-form lat/lng-to-screen projection,
253    /// far hemisphere culled).
254    #[serde(default)]
255    pub projection: DotMapProjection,
256    /// Orthographic-only: which meridian/parallel faces the camera. Inert
257    /// under `equirectangular`.
258    #[serde(default)]
259    pub rotate: Option<GlobeRotation>,
260    /// Orthographic-only: darkens dots towards the limb, 0 (none, default)
261    /// to 1 (full black at the very edge). Inert under `equirectangular`.
262    #[serde(default)]
263    pub limb_shading: Option<f32>,
264    /// Orthographic-only: great-circle arcs lifted off the surface. Inert
265    /// under `equirectangular`.
266    #[serde(default)]
267    pub arcs: Vec<GreatCircleArc>,
268    #[serde(flatten)]
269    pub timing: TimingConfig,
270    #[serde(default)]
271    pub style: CssStyle,
272    #[serde(default)]
273    pub timeline: Vec<TimelineStep>,
274    #[serde(default)]
275    pub stagger: Option<f32>,
276}
277
278rustmotion_core::impl_traits!(DotMap {
279    Animatable => animation,
280    Timed => timing,
281    Styled => style,
282});
283
284fn is_land_screen(nx: f32, ny: f32) -> bool {
285    let (lat, lng) = screen_to_geo(nx, ny);
286    geo_is_land(lat, lng)
287}
288
289impl DotMap {
290    fn progress_at(&self, time: f64) -> f32 {
291        if !self.animated {
292            return 1.0;
293        }
294        let start = self.timing.start_at.unwrap_or(0.0);
295        let elapsed = (time - start).max(0.0);
296        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
297        1.0 - (1.0 - p).powi(3)
298    }
299
300    fn paint(
301        &self,
302        canvas: &Canvas,
303        layout_w: f32,
304        layout_h: f32,
305        ctx: &PaintCtx,
306        arc_draw_progress: f32,
307    ) {
308        match self.projection {
309            DotMapProjection::Equirectangular => {
310                self.paint_equirectangular(canvas, layout_w, layout_h, ctx.time)
311            }
312            DotMapProjection::Orthographic => {
313                self.paint_orthographic(canvas, layout_w, layout_h, ctx.time, arc_draw_progress)
314            }
315        }
316    }
317
318    fn globe_center(&self, time: f64) -> (f64, f64) {
319        let start_at = self.timing.start_at.unwrap_or(0.0);
320        let center_lng = self
321            .rotate
322            .as_ref()
323            .and_then(|r| r.lng.as_ref())
324            .map(|a| a.value_at(time, start_at, self.animation_duration) as f64)
325            .unwrap_or(0.0);
326        let center_lat = self
327            .rotate
328            .as_ref()
329            .and_then(|r| r.lat)
330            .map(|v| v as f64)
331            .unwrap_or(0.0);
332        (center_lat, center_lng)
333    }
334
335    fn paint_orthographic(
336        &self,
337        canvas: &Canvas,
338        layout_w: f32,
339        layout_h: f32,
340        time: f64,
341        arc_draw_progress: f32,
342    ) {
343        let w = layout_w;
344        let h = layout_h;
345        let progress = self.progress_at(time);
346
347        let mut bg_paint = paint_from_hex(&self.background_color);
348        bg_paint.set_style(PaintStyle::Fill);
349        bg_paint.set_anti_alias(true);
350        canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, h), &bg_paint);
351
352        let radius = (w.min(h) * 0.5 * ORTHOGRAPHIC_DISC_MARGIN).max(1.0);
353        let center = (w / 2.0, h / 2.0);
354        let (center_lat, center_lng) = self.globe_center(time);
355
356        if self.show_world {
357            self.paint_world_dots_orthographic(canvas, center, radius, center_lat, center_lng);
358        }
359
360        self.paint_points_orthographic(
361            canvas, center, radius, center_lat, center_lng, progress, time,
362        );
363        self.paint_arcs_orthographic(
364            canvas,
365            center,
366            radius,
367            center_lat,
368            center_lng,
369            arc_draw_progress,
370        );
371    }
372
373    fn paint_world_dots_orthographic(
374        &self,
375        canvas: &Canvas,
376        center: (f32, f32),
377        radius: f32,
378        center_lat: f64,
379        center_lng: f64,
380    ) {
381        let mut world_paint = paint_from_hex(&self.world_dot_color);
382        world_paint.set_style(PaintStyle::Fill);
383        world_paint.set_anti_alias(true);
384
385        let step_rad = (self.dot_spacing.max(1.0) as f64 / radius as f64).max(0.002);
386        let step_deg = step_rad.to_degrees().max(0.5);
387
388        const MAX_STEPS_PER_AXIS: u32 = 2048;
389        let lat_steps = ((170.0 / step_deg) as u32 + 1).min(MAX_STEPS_PER_AXIS);
390        let lng_steps = ((360.0 / step_deg) as u32 + 1).min(MAX_STEPS_PER_AXIS);
391
392        for lat_i in 0..=lat_steps {
393            let lat = LAT_MIN + lat_i as f64 * step_deg;
394            if lat > LAT_MAX {
395                continue;
396            }
397            for lng_i in 0..=lng_steps {
398                let lng = -180.0 + lng_i as f64 * step_deg;
399                if lng > 180.0 {
400                    continue;
401                }
402                if !geo_is_land(lat, lng) {
403                    continue;
404                }
405                let Some((x, y, cos_c)) = orthographic_project(lat, lng, center_lat, center_lng)
406                else {
407                    continue;
408                };
409                let shade = limb_shade(self.limb_shading, cos_c);
410                let px = center.0 + x as f32 * radius;
411                let py = center.1 - y as f32 * radius;
412                world_paint.set_alpha_f(shade);
413                canvas.draw_circle((px, py), self.dot_radius, &world_paint);
414            }
415        }
416    }
417
418    #[allow(clippy::too_many_arguments)]
419    fn paint_points_orthographic(
420        &self,
421        canvas: &Canvas,
422        center: (f32, f32),
423        radius: f32,
424        center_lat: f64,
425        center_lng: f64,
426        progress: f32,
427        time: f64,
428    ) {
429        let default_color = "#3B82F6";
430        let default_dot_size = 10.0_f32;
431        let label_font_size = 12.0_f32;
432        let point_count = self.points.len();
433
434        let font_style = skia_safe::FontStyle::normal();
435        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
436            return;
437        };
438        let label_font = subpixel_font(typeface, label_font_size);
439        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, label_font_size));
440
441        for (i, point) in self.points.iter().enumerate() {
442            let dot_alpha = if self.animated {
443                let stagger_delay = if point_count > 1 {
444                    (i as f64 / point_count as f64) * 0.6
445                } else {
446                    0.0
447                };
448                let dot_progress = ((time - stagger_delay) / (self.animation_duration * 0.4))
449                    .clamp(0.0, 1.0) as f32;
450                dot_progress * progress
451            } else {
452                1.0
453            };
454
455            if dot_alpha <= 0.0 {
456                continue;
457            }
458
459            let Some((x, y, cos_c)) =
460                orthographic_project(point.lat, point.lng, center_lat, center_lng)
461            else {
462                continue;
463            };
464            let dot_alpha = dot_alpha * limb_shade(self.limb_shading, cos_c);
465            let px = center.0 + x as f32 * radius;
466            let py = center.1 - y as f32 * radius;
467            let dot_size = point.size.unwrap_or(default_dot_size);
468            let color_str = point.color.as_deref().unwrap_or(default_color);
469
470            if point.pulse.unwrap_or(false) {
471                let num_rings = 2;
472                for ring in 0..num_rings {
473                    let phase = ((time * 1.5 + ring as f64 * 0.5).fract()) as f32;
474                    let ring_radius = dot_size * (1.0 + phase * 2.5);
475                    let ring_alpha = (1.0 - phase).max(0.0) * 0.4 * dot_alpha;
476
477                    let mut pulse_paint = paint_from_hex(color_str);
478                    pulse_paint.set_style(PaintStyle::Stroke);
479                    pulse_paint.set_stroke_width(2.0);
480                    pulse_paint.set_anti_alias(true);
481                    pulse_paint.set_alpha_f(ring_alpha);
482                    canvas.draw_circle((px, py), ring_radius, &pulse_paint);
483                }
484            }
485
486            let mut dot_paint = paint_from_hex(color_str);
487            dot_paint.set_style(PaintStyle::Fill);
488            dot_paint.set_anti_alias(true);
489            dot_paint.set_alpha_f(dot_alpha);
490            canvas.draw_circle((px, py), dot_size / 2.0, &dot_paint);
491
492            let mut border_paint = paint_from_hex("#FFFFFF");
493            border_paint.set_style(PaintStyle::Stroke);
494            border_paint.set_stroke_width(1.5);
495            border_paint.set_anti_alias(true);
496            border_paint.set_alpha_f(dot_alpha * 0.6);
497            canvas.draw_circle((px, py), dot_size / 2.0, &border_paint);
498
499            if let Some(label) = &point.label {
500                let mut label_paint = paint_from_hex("#FFFFFF");
501                label_paint.set_anti_alias(true);
502                label_paint.set_alpha_f(dot_alpha * 0.9);
503
504                let text_w = measure_text_with_fallback(label, &label_font, &emoji_font, 0.0);
505                let label_x = px - text_w / 2.0;
506                let label_y = py + dot_size / 2.0 + label_font_size + 4.0;
507
508                draw_text_with_fallback(
509                    canvas,
510                    label,
511                    &label_font,
512                    &emoji_font,
513                    0.0,
514                    label_x,
515                    label_y,
516                    &label_paint,
517                );
518            }
519        }
520    }
521
522    #[allow(clippy::too_many_arguments)]
523    fn paint_arcs_orthographic(
524        &self,
525        canvas: &Canvas,
526        center: (f32, f32),
527        radius: f32,
528        center_lat: f64,
529        center_lng: f64,
530        arc_draw_progress: f32,
531    ) {
532        let animated_reveal = if arc_draw_progress >= 0.0 {
533            arc_draw_progress.clamp(0.0, 1.0)
534        } else {
535            1.0
536        };
537        for arc in &self.arcs {
538            let max_t = (arc.draw_in.clamp(0.0, 1.0) * animated_reveal) as f64;
539            if max_t <= 0.0 {
540                continue;
541            }
542            let a = latlng_to_unit(arc.from[0], arc.from[1]);
543            let b = latlng_to_unit(arc.to[0], arc.to[1]);
544            let altitude = arc.altitude.unwrap_or(ARC_DEFAULT_ALTITUDE);
545            let color = arc.color.as_deref().unwrap_or(ARC_DEFAULT_COLOR);
546
547            let mut paint = paint_from_hex(color);
548            paint.set_style(PaintStyle::Stroke);
549            paint.set_stroke_width(ARC_STROKE_WIDTH);
550            paint.set_anti_alias(true);
551            paint.set_stroke_cap(skia_safe::PaintCap::Round);
552
553            let mut prev: Option<(f32, f32)> = None;
554            for i in 0..=ARC_STEPS {
555                let s = (i as f64 / ARC_STEPS as f64) * max_t;
556                let v = slerp_unit(a, b, s);
557                let lift = altitude as f64 * (std::f64::consts::PI * s).sin();
558                let (lat, lng) = unit_to_latlng(v);
559
560                match orthographic_project(lat, lng, center_lat, center_lng) {
561                    Some((x, y, cos_c)) => {
562                        let r = radius * (1.0 + lift as f32);
563                        let px = center.0 + x as f32 * r;
564                        let py = center.1 - y as f32 * r;
565                        if let Some(prev_pt) = prev {
566                            paint.set_alpha_f(limb_shade(self.limb_shading, cos_c));
567                            canvas.draw_line(prev_pt, (px, py), &paint);
568                        }
569                        prev = Some((px, py));
570                    }
571                    None => prev = None,
572                }
573            }
574        }
575    }
576
577    fn paint_equirectangular(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
578        let w = layout_w;
579        let h = layout_h;
580        let progress = self.progress_at(time);
581
582        let mut bg_paint = paint_from_hex(&self.background_color);
583        bg_paint.set_style(PaintStyle::Fill);
584        bg_paint.set_anti_alias(true);
585        canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, h), &bg_paint);
586
587        if self.show_world {
588            let mut world_paint = paint_from_hex(&self.world_dot_color);
589            world_paint.set_style(PaintStyle::Fill);
590            world_paint.set_anti_alias(true);
591
592            let spacing = if self.dot_spacing.is_finite() && self.dot_spacing >= 1.0 {
593                self.dot_spacing
594            } else {
595                1.0
596            };
597            let radius = self.dot_radius;
598            let margin = spacing;
599
600            const MAX_DOTS_PER_AXIS: u32 = 4096;
601            let cols = (((w - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
602            let rows = (((h - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
603
604            for row in 0..rows {
605                for col in 0..cols {
606                    let px = margin + col as f32 * spacing;
607                    let py = margin + row as f32 * spacing;
608
609                    let nx = px / w;
610                    let ny = py / h;
611
612                    if is_land_screen(nx, ny) {
613                        canvas.draw_circle((px, py), radius, &world_paint);
614                    }
615                }
616            }
617        }
618
619        let default_color = "#3B82F6";
620        let default_dot_size = 10.0_f32;
621        let label_font_size = 12.0_f32;
622        let point_count = self.points.len();
623
624        let font_style = skia_safe::FontStyle::normal();
625        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
626            return;
627        };
628        let label_font = subpixel_font(typeface, label_font_size);
629        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, label_font_size));
630
631        for (i, point) in self.points.iter().enumerate() {
632            let dot_alpha = if self.animated {
633                let stagger_delay = if point_count > 1 {
634                    (i as f64 / point_count as f64) * 0.6
635                } else {
636                    0.0
637                };
638                let dot_progress = ((time - stagger_delay) / (self.animation_duration * 0.4))
639                    .clamp(0.0, 1.0) as f32;
640                dot_progress * progress
641            } else {
642                1.0
643            };
644
645            if dot_alpha <= 0.0 {
646                continue;
647            }
648
649            let (nx, ny) = geo_to_screen(point.lat, point.lng);
650            let px = nx * w;
651            let py = ny * h;
652            let dot_size = point.size.unwrap_or(default_dot_size);
653            let color_str = point.color.as_deref().unwrap_or(default_color);
654
655            if point.pulse.unwrap_or(false) {
656                let num_rings = 2;
657                for ring in 0..num_rings {
658                    let phase = ((time * 1.5 + ring as f64 * 0.5).fract()) as f32;
659                    let ring_radius = dot_size * (1.0 + phase * 2.5);
660                    let ring_alpha = (1.0 - phase).max(0.0) * 0.4 * dot_alpha;
661
662                    let mut pulse_paint = paint_from_hex(color_str);
663                    pulse_paint.set_style(PaintStyle::Stroke);
664                    pulse_paint.set_stroke_width(2.0);
665                    pulse_paint.set_anti_alias(true);
666                    pulse_paint.set_alpha_f(ring_alpha);
667                    canvas.draw_circle((px, py), ring_radius, &pulse_paint);
668                }
669            }
670
671            let mut dot_paint = paint_from_hex(color_str);
672            dot_paint.set_style(PaintStyle::Fill);
673            dot_paint.set_anti_alias(true);
674            dot_paint.set_alpha_f(dot_alpha);
675            canvas.draw_circle((px, py), dot_size / 2.0, &dot_paint);
676
677            let mut border_paint = paint_from_hex("#FFFFFF");
678            border_paint.set_style(PaintStyle::Stroke);
679            border_paint.set_stroke_width(1.5);
680            border_paint.set_anti_alias(true);
681            border_paint.set_alpha_f(dot_alpha * 0.6);
682            canvas.draw_circle((px, py), dot_size / 2.0, &border_paint);
683
684            if let Some(label) = &point.label {
685                let mut label_paint = paint_from_hex("#FFFFFF");
686                label_paint.set_anti_alias(true);
687                label_paint.set_alpha_f(dot_alpha * 0.9);
688
689                let text_w = measure_text_with_fallback(label, &label_font, &emoji_font, 0.0);
690                let label_x = px - text_w / 2.0;
691                let label_y = py + dot_size / 2.0 + label_font_size + 4.0;
692
693                draw_text_with_fallback(
694                    canvas,
695                    label,
696                    &label_font,
697                    &emoji_font,
698                    0.0,
699                    label_x,
700                    label_y,
701                    &label_paint,
702                );
703            }
704        }
705    }
706}
707
708impl Painter for DotMap {
709    fn paint_content(
710        &self,
711        canvas: &Canvas,
712        layout: &BoxLayout,
713        props: &AnimatedProperties,
714        ctx: &PaintCtx,
715    ) {
716        self.paint(
717            canvas,
718            layout.width,
719            layout.height,
720            ctx,
721            props.draw_progress,
722        );
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    fn test_ctx(time: f64) -> PaintCtx {
731        PaintCtx {
732            time,
733            scenario_time: time,
734            scene_duration: 2.0,
735            frame_index: 0,
736            fps: 30,
737            video_width: 400,
738            video_height: 400,
739            stagger_offset: 0.0,
740        }
741    }
742
743    fn render(map: &DotMap, w: u32, h: u32, time: f64) -> Vec<u8> {
744        render_via_paint_content(map, w, h, time, -1.0)
745    }
746
747    fn render_via_paint_content(
748        map: &DotMap,
749        w: u32,
750        h: u32,
751        time: f64,
752        draw_progress: f32,
753    ) -> Vec<u8> {
754        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
755        let layout = BoxLayout {
756            x: 0.0,
757            y: 0.0,
758            width: w as f32,
759            height: h as f32,
760            ..Default::default()
761        };
762        let props = AnimatedProperties {
763            draw_progress,
764            ..Default::default()
765        };
766        map.paint_content(surface.canvas(), &layout, &props, &test_ctx(time));
767        let info = skia_safe::ImageInfo::new(
768            (w as i32, h as i32),
769            skia_safe::ColorType::RGBA8888,
770            skia_safe::AlphaType::Unpremul,
771            None,
772        );
773        let mut buf = vec![0u8; (w * h * 4) as usize];
774        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
775        buf
776    }
777
778    fn dot_map_from(extra: serde_json::Value) -> DotMap {
779        let mut base = serde_json::json!({
780            "points": [],
781            "animated": false,
782        });
783        base.as_object_mut()
784            .unwrap()
785            .extend(extra.as_object().unwrap().clone());
786        serde_json::from_value(base).expect("dot_map json must deserialize")
787    }
788
789    #[test]
790    fn projection_defaults_to_equirectangular() {
791        assert_eq!(
792            DotMapProjection::default(),
793            DotMapProjection::Equirectangular
794        );
795        let map = dot_map_from(serde_json::json!({}));
796        assert_eq!(map.projection, DotMapProjection::Equirectangular);
797        assert!(map.rotate.is_none());
798        assert!(map.limb_shading.is_none());
799        assert!(map.arcs.is_empty());
800    }
801
802    #[test]
803    fn omitting_the_new_fields_renders_byte_identically_to_declaring_them_at_default() {
804        let implicit = dot_map_from(serde_json::json!({}));
805        let explicit = dot_map_from(serde_json::json!({
806            "projection": "equirectangular",
807        }));
808        let out_implicit = render(&implicit, 300, 300, 0.4);
809        let out_explicit = render(&explicit, 300, 300, 0.4);
810        assert_eq!(
811            out_implicit, out_explicit,
812            "a dot_map with no projection field must render exactly as one that spells out \
813             the default explicitly — the new fields must not perturb the historical path"
814        );
815    }
816
817    #[test]
818    fn orthographic_hides_the_far_hemisphere() {
819        assert!(orthographic_project(0.0, 0.0, 0.0, 0.0).is_some());
820        assert!(
821            orthographic_project(0.0, 170.0, 0.0, 0.0).is_none(),
822            "a point 170 degrees around the globe from the sub-observer point is on the far side"
823        );
824        assert!(orthographic_project(0.0, 89.0, 0.0, 0.0).is_some());
825        assert!(orthographic_project(0.0, 91.0, 0.0, 0.0).is_none());
826    }
827
828    #[test]
829    fn orthographic_cos_c_is_one_at_centre_and_zero_at_the_limb() {
830        let (_, _, centre) = orthographic_project(0.0, 0.0, 0.0, 0.0).unwrap();
831        let (_, _, limb) = orthographic_project(0.0, 90.0, 0.0, 0.0).unwrap();
832        assert!((centre - 1.0).abs() < 1e-9);
833        assert!(limb.abs() < 1e-9);
834    }
835
836    #[test]
837    fn dot_spacing_converges_towards_the_limb() {
838        let radius = 500.0_f64;
839        let screen_dx = |lng_a: f64, lng_b: f64| {
840            let (xa, _, _) = orthographic_project(0.0, lng_a, 0.0, 0.0).unwrap();
841            let (xb, _, _) = orthographic_project(0.0, lng_b, 0.0, 0.0).unwrap();
842            ((xb - xa) * radius).abs()
843        };
844        let centre_gap = screen_dx(-2.5, 2.5);
845        let edge_gap = screen_dx(77.5, 82.5);
846        assert!(
847            edge_gap < centre_gap * 0.9,
848            "an orthographic globe's dots must converge towards the limb: a 5 degree step must \
849             map to a measurably smaller screen gap near the edge than at the centre (not just \
850             float noise) — centre={centre_gap:.3}px, edge={edge_gap:.3}px"
851        );
852
853        let flat_dx = |lng_a: f64, lng_b: f64| ((lng_b - lng_a) / 360.0 * 800.0).abs();
854        assert_eq!(
855            flat_dx(-2.5, 2.5),
856            flat_dx(77.5, 82.5),
857            "sanity: the flat equirectangular mapping this replaces has no such convergence"
858        );
859    }
860
861    #[test]
862    fn a_rotated_view_recentres_which_longitude_is_visible() {
863        assert!(orthographic_project(0.0, 100.0, 0.0, 0.0).is_none());
864        assert!(
865            orthographic_project(0.0, 100.0, 0.0, 100.0).is_some(),
866            "rotating the sub-observer longitude to 100 degrees must bring that meridian \
867             into view"
868        );
869    }
870
871    #[test]
872    fn limb_shading_darkens_towards_the_edge_and_is_inert_when_unset() {
873        assert_eq!(limb_shade(None, 0.0), 1.0);
874        assert_eq!(limb_shade(Some(1.0), 1.0), 1.0);
875        assert_eq!(limb_shade(Some(1.0), 0.0), 0.0);
876        assert!((limb_shade(Some(0.5), 0.0) - 0.5).abs() < 1e-6);
877    }
878
879    #[test]
880    fn slerp_and_unit_conversion_round_trip_a_few_points() {
881        for (lat, lng) in [(0.0, 0.0), (45.0, -30.0), (-60.0, 120.0), (10.0, 179.0)] {
882            let v = latlng_to_unit(lat, lng);
883            let (lat2, lng2) = unit_to_latlng(v);
884            assert!((lat - lat2).abs() < 1e-6, "lat round trip: {lat} vs {lat2}");
885            assert!((lng - lng2).abs() < 1e-6, "lng round trip: {lng} vs {lng2}");
886        }
887    }
888
889    #[test]
890    fn rotation_animates_linearly_from_start_at_to_the_fallback_duration() {
891        let angle = RotationAngle::Animated {
892            from: -20.0,
893            to: 40.0,
894            duration: None,
895        };
896        assert_eq!(angle.value_at(0.0, 0.0, 1.5), -20.0);
897        assert_eq!(angle.value_at(1.5, 0.0, 1.5), 40.0);
898        assert!((angle.value_at(0.75, 0.0, 1.5) - 10.0).abs() < 0.001);
899    }
900
901    #[test]
902    fn an_orthographic_globe_paints_a_visibly_different_frame_from_the_flat_map() {
903        let flat = dot_map_from(serde_json::json!({
904            "points": [{"lat": 48.85, "lng": 2.35, "size": 20.0}],
905            "show_world": false,
906        }));
907        let globe = dot_map_from(serde_json::json!({
908            "projection": "orthographic",
909            "points": [{"lat": 48.85, "lng": 2.35, "size": 20.0}],
910            "show_world": false,
911        }));
912        let out_flat = render(&flat, 300, 300, 1.0);
913        let out_globe = render(&globe, 300, 300, 1.0);
914        assert_ne!(
915            out_flat, out_globe,
916            "an orthographic dot_map must not paint the flat equirectangular frame"
917        );
918    }
919
920    fn has_orange_pixel(buf: &[u8]) -> bool {
921        buf.as_chunks::<4>()
922            .0
923            .iter()
924            .any(|p| p[0] > 200 && p[1] > 90 && p[1] < 180 && p[2] < 60)
925    }
926
927    #[test]
928    fn an_arc_on_the_near_side_is_visible_but_one_entirely_on_the_far_side_is_culled() {
929        let visible = dot_map_from(serde_json::json!({
930            "projection": "orthographic",
931            "points": [],
932            "show_world": false,
933            "arcs": [{"from": [10.0, -30.0], "to": [10.0, 30.0], "draw_in": 1.0}],
934        }));
935        let hidden = dot_map_from(serde_json::json!({
936            "projection": "orthographic",
937            "points": [],
938            "show_world": false,
939            "arcs": [{"from": [10.0, 150.0], "to": [10.0, 170.0], "draw_in": 1.0}],
940        }));
941        let out_visible = render(&visible, 300, 300, 1.0);
942        let out_hidden = render(&hidden, 300, 300, 1.0);
943        assert!(
944            has_orange_pixel(&out_visible),
945            "an arc entirely on the near hemisphere must paint its (default orange) stroke"
946        );
947        assert!(
948            !has_orange_pixel(&out_hidden),
949            "an arc entirely on the far hemisphere must be culled, not drawn straight through \
950             the globe"
951        );
952    }
953
954    #[test]
955    fn animating_the_nodes_draw_progress_sweeps_an_arc_in_instead_of_being_ignored() {
956        let map = dot_map_from(serde_json::json!({
957            "projection": "orthographic",
958            "points": [],
959            "show_world": false,
960            "arcs": [{"from": [10.0, -30.0], "to": [10.0, 30.0], "draw_in": 1.0}],
961        }));
962        let at_zero = render_via_paint_content(&map, 300, 300, 1.0, 0.0);
963        let at_full = render_via_paint_content(&map, 300, 300, 1.0, 1.0);
964        assert!(
965            !has_orange_pixel(&at_zero),
966            "an animated draw_progress of 0.0 on the dot_map node must paint no arc stroke \
967             at all, not the fully-drawn arc"
968        );
969        assert!(
970            has_orange_pixel(&at_full),
971            "an animated draw_progress of 1.0 on the dot_map node must paint the arc's \
972             (default orange) stroke"
973        );
974    }
975
976    #[test]
977    fn a_static_draw_in_still_caps_the_arc_once_draw_progress_is_fully_animated_in() {
978        let capped = dot_map_from(serde_json::json!({
979            "projection": "orthographic",
980            "points": [],
981            "show_world": false,
982            "arcs": [{"from": [10.0, -60.0], "to": [10.0, 60.0], "draw_in": 0.2}],
983        }));
984        let full = dot_map_from(serde_json::json!({
985            "projection": "orthographic",
986            "points": [],
987            "show_world": false,
988            "arcs": [{"from": [10.0, -60.0], "to": [10.0, 60.0], "draw_in": 1.0}],
989        }));
990        let out_capped = render_via_paint_content(&capped, 300, 300, 1.0, 1.0);
991        let out_full = render_via_paint_content(&full, 300, 300, 1.0, 1.0);
992        assert_ne!(
993            out_capped, out_full,
994            "draw_in must still cap how much of the arc paints even once the node's own \
995             animated draw_progress has reached 1.0"
996        );
997    }
998}