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    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 = skia_safe::Font::from_typeface(typeface, label_font_size);
439        let emoji_font =
440            emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size));
441
442        for (i, point) in self.points.iter().enumerate() {
443            let dot_alpha = if self.animated {
444                let stagger_delay = if point_count > 1 {
445                    (i as f64 / point_count as f64) * 0.6
446                } else {
447                    0.0
448                };
449                let dot_progress = ((time - stagger_delay) / (self.animation_duration * 0.4))
450                    .clamp(0.0, 1.0) as f32;
451                dot_progress * progress
452            } else {
453                1.0
454            };
455
456            if dot_alpha <= 0.0 {
457                continue;
458            }
459
460            let Some((x, y, cos_c)) =
461                orthographic_project(point.lat, point.lng, center_lat, center_lng)
462            else {
463                continue;
464            };
465            let dot_alpha = dot_alpha * limb_shade(self.limb_shading, cos_c);
466            let px = center.0 + x as f32 * radius;
467            let py = center.1 - y as f32 * radius;
468            let dot_size = point.size.unwrap_or(default_dot_size);
469            let color_str = point.color.as_deref().unwrap_or(default_color);
470
471            if point.pulse.unwrap_or(false) {
472                let num_rings = 2;
473                for ring in 0..num_rings {
474                    let phase = ((time * 1.5 + ring as f64 * 0.5).fract()) as f32;
475                    let ring_radius = dot_size * (1.0 + phase * 2.5);
476                    let ring_alpha = (1.0 - phase).max(0.0) * 0.4 * dot_alpha;
477
478                    let mut pulse_paint = paint_from_hex(color_str);
479                    pulse_paint.set_style(PaintStyle::Stroke);
480                    pulse_paint.set_stroke_width(2.0);
481                    pulse_paint.set_anti_alias(true);
482                    pulse_paint.set_alpha_f(ring_alpha);
483                    canvas.draw_circle((px, py), ring_radius, &pulse_paint);
484                }
485            }
486
487            let mut dot_paint = paint_from_hex(color_str);
488            dot_paint.set_style(PaintStyle::Fill);
489            dot_paint.set_anti_alias(true);
490            dot_paint.set_alpha_f(dot_alpha);
491            canvas.draw_circle((px, py), dot_size / 2.0, &dot_paint);
492
493            let mut border_paint = paint_from_hex("#FFFFFF");
494            border_paint.set_style(PaintStyle::Stroke);
495            border_paint.set_stroke_width(1.5);
496            border_paint.set_anti_alias(true);
497            border_paint.set_alpha_f(dot_alpha * 0.6);
498            canvas.draw_circle((px, py), dot_size / 2.0, &border_paint);
499
500            if let Some(label) = &point.label {
501                let mut label_paint = paint_from_hex("#FFFFFF");
502                label_paint.set_anti_alias(true);
503                label_paint.set_alpha_f(dot_alpha * 0.9);
504
505                let text_w = measure_text_with_fallback(label, &label_font, &emoji_font, 0.0);
506                let label_x = px - text_w / 2.0;
507                let label_y = py + dot_size / 2.0 + label_font_size + 4.0;
508
509                draw_text_with_fallback(
510                    canvas,
511                    label,
512                    &label_font,
513                    &emoji_font,
514                    0.0,
515                    label_x,
516                    label_y,
517                    &label_paint,
518                );
519            }
520        }
521    }
522
523    #[allow(clippy::too_many_arguments)]
524    fn paint_arcs_orthographic(
525        &self,
526        canvas: &Canvas,
527        center: (f32, f32),
528        radius: f32,
529        center_lat: f64,
530        center_lng: f64,
531        arc_draw_progress: f32,
532    ) {
533        let animated_reveal = if arc_draw_progress >= 0.0 {
534            arc_draw_progress.clamp(0.0, 1.0)
535        } else {
536            1.0
537        };
538        for arc in &self.arcs {
539            let max_t = (arc.draw_in.clamp(0.0, 1.0) * animated_reveal) as f64;
540            if max_t <= 0.0 {
541                continue;
542            }
543            let a = latlng_to_unit(arc.from[0], arc.from[1]);
544            let b = latlng_to_unit(arc.to[0], arc.to[1]);
545            let altitude = arc.altitude.unwrap_or(ARC_DEFAULT_ALTITUDE);
546            let color = arc.color.as_deref().unwrap_or(ARC_DEFAULT_COLOR);
547
548            let mut paint = paint_from_hex(color);
549            paint.set_style(PaintStyle::Stroke);
550            paint.set_stroke_width(ARC_STROKE_WIDTH);
551            paint.set_anti_alias(true);
552            paint.set_stroke_cap(skia_safe::PaintCap::Round);
553
554            let mut prev: Option<(f32, f32)> = None;
555            for i in 0..=ARC_STEPS {
556                let s = (i as f64 / ARC_STEPS as f64) * max_t;
557                let v = slerp_unit(a, b, s);
558                let lift = altitude as f64 * (std::f64::consts::PI * s).sin();
559                let (lat, lng) = unit_to_latlng(v);
560
561                match orthographic_project(lat, lng, center_lat, center_lng) {
562                    Some((x, y, cos_c)) => {
563                        let r = radius * (1.0 + lift as f32);
564                        let px = center.0 + x as f32 * r;
565                        let py = center.1 - y as f32 * r;
566                        if let Some(prev_pt) = prev {
567                            paint.set_alpha_f(limb_shade(self.limb_shading, cos_c));
568                            canvas.draw_line(prev_pt, (px, py), &paint);
569                        }
570                        prev = Some((px, py));
571                    }
572                    None => prev = None,
573                }
574            }
575        }
576    }
577
578    fn paint_equirectangular(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
579        let w = layout_w;
580        let h = layout_h;
581        let progress = self.progress_at(time);
582
583        let mut bg_paint = paint_from_hex(&self.background_color);
584        bg_paint.set_style(PaintStyle::Fill);
585        bg_paint.set_anti_alias(true);
586        canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, h), &bg_paint);
587
588        if self.show_world {
589            let mut world_paint = paint_from_hex(&self.world_dot_color);
590            world_paint.set_style(PaintStyle::Fill);
591            world_paint.set_anti_alias(true);
592
593            let spacing = if self.dot_spacing.is_finite() && self.dot_spacing >= 1.0 {
594                self.dot_spacing
595            } else {
596                1.0
597            };
598            let radius = self.dot_radius;
599            let margin = spacing;
600
601            const MAX_DOTS_PER_AXIS: u32 = 4096;
602            let cols = (((w - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
603            let rows = (((h - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
604
605            for row in 0..rows {
606                for col in 0..cols {
607                    let px = margin + col as f32 * spacing;
608                    let py = margin + row as f32 * spacing;
609
610                    let nx = px / w;
611                    let ny = py / h;
612
613                    if is_land_screen(nx, ny) {
614                        canvas.draw_circle((px, py), radius, &world_paint);
615                    }
616                }
617            }
618        }
619
620        let default_color = "#3B82F6";
621        let default_dot_size = 10.0_f32;
622        let label_font_size = 12.0_f32;
623        let point_count = self.points.len();
624
625        let font_style = skia_safe::FontStyle::normal();
626        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
627            return;
628        };
629        let label_font = skia_safe::Font::from_typeface(typeface, label_font_size);
630        let emoji_font =
631            emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size));
632
633        for (i, point) in self.points.iter().enumerate() {
634            let dot_alpha = if self.animated {
635                let stagger_delay = if point_count > 1 {
636                    (i as f64 / point_count as f64) * 0.6
637                } else {
638                    0.0
639                };
640                let dot_progress = ((time - stagger_delay) / (self.animation_duration * 0.4))
641                    .clamp(0.0, 1.0) as f32;
642                dot_progress * progress
643            } else {
644                1.0
645            };
646
647            if dot_alpha <= 0.0 {
648                continue;
649            }
650
651            let (nx, ny) = geo_to_screen(point.lat, point.lng);
652            let px = nx * w;
653            let py = ny * h;
654            let dot_size = point.size.unwrap_or(default_dot_size);
655            let color_str = point.color.as_deref().unwrap_or(default_color);
656
657            if point.pulse.unwrap_or(false) {
658                let num_rings = 2;
659                for ring in 0..num_rings {
660                    let phase = ((time * 1.5 + ring as f64 * 0.5).fract()) as f32;
661                    let ring_radius = dot_size * (1.0 + phase * 2.5);
662                    let ring_alpha = (1.0 - phase).max(0.0) * 0.4 * dot_alpha;
663
664                    let mut pulse_paint = paint_from_hex(color_str);
665                    pulse_paint.set_style(PaintStyle::Stroke);
666                    pulse_paint.set_stroke_width(2.0);
667                    pulse_paint.set_anti_alias(true);
668                    pulse_paint.set_alpha_f(ring_alpha);
669                    canvas.draw_circle((px, py), ring_radius, &pulse_paint);
670                }
671            }
672
673            let mut dot_paint = paint_from_hex(color_str);
674            dot_paint.set_style(PaintStyle::Fill);
675            dot_paint.set_anti_alias(true);
676            dot_paint.set_alpha_f(dot_alpha);
677            canvas.draw_circle((px, py), dot_size / 2.0, &dot_paint);
678
679            let mut border_paint = paint_from_hex("#FFFFFF");
680            border_paint.set_style(PaintStyle::Stroke);
681            border_paint.set_stroke_width(1.5);
682            border_paint.set_anti_alias(true);
683            border_paint.set_alpha_f(dot_alpha * 0.6);
684            canvas.draw_circle((px, py), dot_size / 2.0, &border_paint);
685
686            if let Some(label) = &point.label {
687                let mut label_paint = paint_from_hex("#FFFFFF");
688                label_paint.set_anti_alias(true);
689                label_paint.set_alpha_f(dot_alpha * 0.9);
690
691                let text_w = measure_text_with_fallback(label, &label_font, &emoji_font, 0.0);
692                let label_x = px - text_w / 2.0;
693                let label_y = py + dot_size / 2.0 + label_font_size + 4.0;
694
695                draw_text_with_fallback(
696                    canvas,
697                    label,
698                    &label_font,
699                    &emoji_font,
700                    0.0,
701                    label_x,
702                    label_y,
703                    &label_paint,
704                );
705            }
706        }
707    }
708}
709
710impl Painter for DotMap {
711    fn paint_content(
712        &self,
713        canvas: &Canvas,
714        layout: &BoxLayout,
715        props: &AnimatedProperties,
716        ctx: &PaintCtx,
717    ) {
718        self.paint(
719            canvas,
720            layout.width,
721            layout.height,
722            ctx,
723            props.draw_progress,
724        );
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    fn test_ctx(time: f64) -> PaintCtx {
733        PaintCtx {
734            time,
735            scenario_time: time,
736            scene_duration: 2.0,
737            frame_index: 0,
738            fps: 30,
739            video_width: 400,
740            video_height: 400,
741            stagger_offset: 0.0,
742        }
743    }
744
745    fn render(map: &DotMap, w: u32, h: u32, time: f64) -> Vec<u8> {
746        render_via_paint_content(map, w, h, time, -1.0)
747    }
748
749    fn render_via_paint_content(
750        map: &DotMap,
751        w: u32,
752        h: u32,
753        time: f64,
754        draw_progress: f32,
755    ) -> Vec<u8> {
756        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
757        let layout = BoxLayout {
758            x: 0.0,
759            y: 0.0,
760            width: w as f32,
761            height: h as f32,
762            ..Default::default()
763        };
764        let props = AnimatedProperties {
765            draw_progress,
766            ..Default::default()
767        };
768        map.paint_content(surface.canvas(), &layout, &props, &test_ctx(time));
769        let info = skia_safe::ImageInfo::new(
770            (w as i32, h as i32),
771            skia_safe::ColorType::RGBA8888,
772            skia_safe::AlphaType::Unpremul,
773            None,
774        );
775        let mut buf = vec![0u8; (w * h * 4) as usize];
776        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
777        buf
778    }
779
780    fn dot_map_from(extra: serde_json::Value) -> DotMap {
781        let mut base = serde_json::json!({
782            "points": [],
783            "animated": false,
784        });
785        base.as_object_mut()
786            .unwrap()
787            .extend(extra.as_object().unwrap().clone());
788        serde_json::from_value(base).expect("dot_map json must deserialize")
789    }
790
791    #[test]
792    fn projection_defaults_to_equirectangular() {
793        assert_eq!(
794            DotMapProjection::default(),
795            DotMapProjection::Equirectangular
796        );
797        let map = dot_map_from(serde_json::json!({}));
798        assert_eq!(map.projection, DotMapProjection::Equirectangular);
799        assert!(map.rotate.is_none());
800        assert!(map.limb_shading.is_none());
801        assert!(map.arcs.is_empty());
802    }
803
804    #[test]
805    fn omitting_the_new_fields_renders_byte_identically_to_declaring_them_at_default() {
806        let implicit = dot_map_from(serde_json::json!({}));
807        let explicit = dot_map_from(serde_json::json!({
808            "projection": "equirectangular",
809        }));
810        let out_implicit = render(&implicit, 300, 300, 0.4);
811        let out_explicit = render(&explicit, 300, 300, 0.4);
812        assert_eq!(
813            out_implicit, out_explicit,
814            "a dot_map with no projection field must render exactly as one that spells out \
815             the default explicitly — the new fields must not perturb the historical path"
816        );
817    }
818
819    #[test]
820    fn orthographic_hides_the_far_hemisphere() {
821        assert!(orthographic_project(0.0, 0.0, 0.0, 0.0).is_some());
822        assert!(
823            orthographic_project(0.0, 170.0, 0.0, 0.0).is_none(),
824            "a point 170 degrees around the globe from the sub-observer point is on the far side"
825        );
826        assert!(orthographic_project(0.0, 89.0, 0.0, 0.0).is_some());
827        assert!(orthographic_project(0.0, 91.0, 0.0, 0.0).is_none());
828    }
829
830    #[test]
831    fn orthographic_cos_c_is_one_at_centre_and_zero_at_the_limb() {
832        let (_, _, centre) = orthographic_project(0.0, 0.0, 0.0, 0.0).unwrap();
833        let (_, _, limb) = orthographic_project(0.0, 90.0, 0.0, 0.0).unwrap();
834        assert!((centre - 1.0).abs() < 1e-9);
835        assert!(limb.abs() < 1e-9);
836    }
837
838    #[test]
839    fn dot_spacing_converges_towards_the_limb() {
840        let radius = 500.0_f64;
841        let screen_dx = |lng_a: f64, lng_b: f64| {
842            let (xa, _, _) = orthographic_project(0.0, lng_a, 0.0, 0.0).unwrap();
843            let (xb, _, _) = orthographic_project(0.0, lng_b, 0.0, 0.0).unwrap();
844            ((xb - xa) * radius).abs()
845        };
846        let centre_gap = screen_dx(-2.5, 2.5);
847        let edge_gap = screen_dx(77.5, 82.5);
848        assert!(
849            edge_gap < centre_gap * 0.9,
850            "an orthographic globe's dots must converge towards the limb: a 5 degree step must \
851             map to a measurably smaller screen gap near the edge than at the centre (not just \
852             float noise) — centre={centre_gap:.3}px, edge={edge_gap:.3}px"
853        );
854
855        let flat_dx = |lng_a: f64, lng_b: f64| ((lng_b - lng_a) / 360.0 * 800.0).abs();
856        assert_eq!(
857            flat_dx(-2.5, 2.5),
858            flat_dx(77.5, 82.5),
859            "sanity: the flat equirectangular mapping this replaces has no such convergence"
860        );
861    }
862
863    #[test]
864    fn a_rotated_view_recentres_which_longitude_is_visible() {
865        assert!(orthographic_project(0.0, 100.0, 0.0, 0.0).is_none());
866        assert!(
867            orthographic_project(0.0, 100.0, 0.0, 100.0).is_some(),
868            "rotating the sub-observer longitude to 100 degrees must bring that meridian \
869             into view"
870        );
871    }
872
873    #[test]
874    fn limb_shading_darkens_towards_the_edge_and_is_inert_when_unset() {
875        assert_eq!(limb_shade(None, 0.0), 1.0);
876        assert_eq!(limb_shade(Some(1.0), 1.0), 1.0);
877        assert_eq!(limb_shade(Some(1.0), 0.0), 0.0);
878        assert!((limb_shade(Some(0.5), 0.0) - 0.5).abs() < 1e-6);
879    }
880
881    #[test]
882    fn slerp_and_unit_conversion_round_trip_a_few_points() {
883        for (lat, lng) in [(0.0, 0.0), (45.0, -30.0), (-60.0, 120.0), (10.0, 179.0)] {
884            let v = latlng_to_unit(lat, lng);
885            let (lat2, lng2) = unit_to_latlng(v);
886            assert!((lat - lat2).abs() < 1e-6, "lat round trip: {lat} vs {lat2}");
887            assert!((lng - lng2).abs() < 1e-6, "lng round trip: {lng} vs {lng2}");
888        }
889    }
890
891    #[test]
892    fn rotation_animates_linearly_from_start_at_to_the_fallback_duration() {
893        let angle = RotationAngle::Animated {
894            from: -20.0,
895            to: 40.0,
896            duration: None,
897        };
898        assert_eq!(angle.value_at(0.0, 0.0, 1.5), -20.0);
899        assert_eq!(angle.value_at(1.5, 0.0, 1.5), 40.0);
900        assert!((angle.value_at(0.75, 0.0, 1.5) - 10.0).abs() < 0.001);
901    }
902
903    #[test]
904    fn an_orthographic_globe_paints_a_visibly_different_frame_from_the_flat_map() {
905        let flat = dot_map_from(serde_json::json!({
906            "points": [{"lat": 48.85, "lng": 2.35, "size": 20.0}],
907            "show_world": false,
908        }));
909        let globe = dot_map_from(serde_json::json!({
910            "projection": "orthographic",
911            "points": [{"lat": 48.85, "lng": 2.35, "size": 20.0}],
912            "show_world": false,
913        }));
914        let out_flat = render(&flat, 300, 300, 1.0);
915        let out_globe = render(&globe, 300, 300, 1.0);
916        assert_ne!(
917            out_flat, out_globe,
918            "an orthographic dot_map must not paint the flat equirectangular frame"
919        );
920    }
921
922    fn has_orange_pixel(buf: &[u8]) -> bool {
923        buf.as_chunks::<4>()
924            .0
925            .iter()
926            .any(|p| p[0] > 200 && p[1] > 90 && p[1] < 180 && p[2] < 60)
927    }
928
929    #[test]
930    fn an_arc_on_the_near_side_is_visible_but_one_entirely_on_the_far_side_is_culled() {
931        let visible = dot_map_from(serde_json::json!({
932            "projection": "orthographic",
933            "points": [],
934            "show_world": false,
935            "arcs": [{"from": [10.0, -30.0], "to": [10.0, 30.0], "draw_in": 1.0}],
936        }));
937        let hidden = dot_map_from(serde_json::json!({
938            "projection": "orthographic",
939            "points": [],
940            "show_world": false,
941            "arcs": [{"from": [10.0, 150.0], "to": [10.0, 170.0], "draw_in": 1.0}],
942        }));
943        let out_visible = render(&visible, 300, 300, 1.0);
944        let out_hidden = render(&hidden, 300, 300, 1.0);
945        assert!(
946            has_orange_pixel(&out_visible),
947            "an arc entirely on the near hemisphere must paint its (default orange) stroke"
948        );
949        assert!(
950            !has_orange_pixel(&out_hidden),
951            "an arc entirely on the far hemisphere must be culled, not drawn straight through \
952             the globe"
953        );
954    }
955
956    #[test]
957    fn animating_the_nodes_draw_progress_sweeps_an_arc_in_instead_of_being_ignored() {
958        let map = dot_map_from(serde_json::json!({
959            "projection": "orthographic",
960            "points": [],
961            "show_world": false,
962            "arcs": [{"from": [10.0, -30.0], "to": [10.0, 30.0], "draw_in": 1.0}],
963        }));
964        let at_zero = render_via_paint_content(&map, 300, 300, 1.0, 0.0);
965        let at_full = render_via_paint_content(&map, 300, 300, 1.0, 1.0);
966        assert!(
967            !has_orange_pixel(&at_zero),
968            "an animated draw_progress of 0.0 on the dot_map node must paint no arc stroke \
969             at all, not the fully-drawn arc"
970        );
971        assert!(
972            has_orange_pixel(&at_full),
973            "an animated draw_progress of 1.0 on the dot_map node must paint the arc's \
974             (default orange) stroke"
975        );
976    }
977
978    #[test]
979    fn a_static_draw_in_still_caps_the_arc_once_draw_progress_is_fully_animated_in() {
980        let capped = dot_map_from(serde_json::json!({
981            "projection": "orthographic",
982            "points": [],
983            "show_world": false,
984            "arcs": [{"from": [10.0, -60.0], "to": [10.0, 60.0], "draw_in": 0.2}],
985        }));
986        let full = dot_map_from(serde_json::json!({
987            "projection": "orthographic",
988            "points": [],
989            "show_world": false,
990            "arcs": [{"from": [10.0, -60.0], "to": [10.0, 60.0], "draw_in": 1.0}],
991        }));
992        let out_capped = render_via_paint_content(&capped, 300, 300, 1.0, 1.0);
993        let out_full = render_via_paint_content(&full, 300, 300, 1.0, 1.0);
994        assert_ne!(
995            out_capped, out_full,
996            "draw_in must still cap how much of the arc paints even once the node's own \
997             animated draw_progress has reached 1.0"
998        );
999    }
1000}