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
15fn default_background_color() -> String {
16    "#0F172A".to_string()
17}
18
19fn default_dot_color() -> String {
20    "#334155".to_string()
21}
22
23fn default_dot_spacing() -> f32 {
24    8.0
25}
26
27fn default_dot_radius() -> f32 {
28    1.5
29}
30
31fn default_animated() -> bool {
32    true
33}
34
35fn default_animation_duration() -> f64 {
36    1.5
37}
38
39fn default_show_world() -> bool {
40    true
41}
42
43#[derive(Debug, Serialize, Deserialize, JsonSchema)]
44pub struct MapPoint {
45    /// Latitude (-90 to 90, positive = north)
46    pub lat: f64,
47    /// Longitude (-180 to 180, positive = east)
48    pub lng: f64,
49    #[serde(default)]
50    pub label: Option<String>,
51    #[serde(default)]
52    pub size: Option<f32>,
53    #[serde(default)]
54    pub color: Option<String>,
55    #[serde(default)]
56    pub pulse: Option<bool>,
57}
58
59// Visible latitude range (clip to habitable world, avoid empty polar regions)
60const LAT_MAX: f64 = 85.0; // top of view
61const LAT_MIN: f64 = -85.0; // bottom of view
62
63/// Convert lat/lng to normalized 0-1 coordinates for rendering on screen.
64fn geo_to_screen(lat: f64, lng: f64) -> (f32, f32) {
65    let x = ((lng + 180.0) / 360.0) as f32;
66    let y = ((LAT_MAX - lat) / (LAT_MAX - LAT_MIN)) as f32;
67    (x, y)
68}
69
70/// Convert screen-normalized coords (0-1) back to lat/lng for bitmap lookup.
71fn screen_to_geo(nx: f32, ny: f32) -> (f64, f64) {
72    let lng = nx as f64 * 360.0 - 180.0;
73    let lat = LAT_MAX - ny as f64 * (LAT_MAX - LAT_MIN);
74    (lat, lng)
75}
76
77/// Check lat/lng against bitmap.
78fn geo_is_land(lat: f64, lng: f64) -> bool {
79    let bitmap = super::world_bitmap::land_bitmap();
80    let col = ((lng + 180.0) / 360.0 * 180.0) as usize;
81    let row = ((90.0 - lat) / 180.0 * 90.0) as usize;
82    if col >= 180 || row >= 90 {
83        return false;
84    }
85    bitmap[row * 180 + col] == 1
86}
87
88#[derive(Debug, Serialize, Deserialize, JsonSchema)]
89pub struct DotMap {
90    pub points: Vec<MapPoint>,
91    #[serde(default = "default_background_color")]
92    pub background_color: String,
93    /// Color of the world map dots
94    #[serde(default = "default_dot_color")]
95    pub world_dot_color: String,
96    /// Spacing between world map dots in pixels
97    #[serde(default = "default_dot_spacing")]
98    pub dot_spacing: f32,
99    /// Radius of each world map dot
100    #[serde(default = "default_dot_radius")]
101    pub dot_radius: f32,
102    /// Show world map dot pattern
103    #[serde(default = "default_show_world")]
104    pub show_world: bool,
105    #[serde(default = "default_animated")]
106    pub animated: bool,
107    #[serde(default = "default_animation_duration")]
108    pub animation_duration: f64,
109    #[serde(flatten)]
110    pub timing: TimingConfig,
111    #[serde(default)]
112    pub style: CssStyle,
113    #[serde(default)]
114    pub timeline: Vec<TimelineStep>,
115    #[serde(default)]
116    pub stagger: Option<f32>,
117}
118
119rustmotion_core::impl_traits!(DotMap {
120    Animatable => animation,
121    Timed => timing,
122    Styled => style,
123});
124
125/// Check if a screen-normalized point (0-1) is land.
126fn is_land_screen(nx: f32, ny: f32) -> bool {
127    let (lat, lng) = screen_to_geo(nx, ny);
128    geo_is_land(lat, lng)
129}
130
131impl DotMap {
132    fn progress_at(&self, time: f64) -> f32 {
133        if !self.animated {
134            return 1.0;
135        }
136        // Measure from `start_at`, like every other animated component: driving
137        // the ramp off raw scene time makes a delayed map arrive already drawn.
138        let start = self.timing.start_at.unwrap_or(0.0);
139        let elapsed = (time - start).max(0.0);
140        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
141        1.0 - (1.0 - p).powi(3)
142    }
143
144    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
145        let w = layout_w;
146        let h = layout_h;
147        let progress = self.progress_at(time);
148
149        // Draw background
150        let mut bg_paint = paint_from_hex(&self.background_color);
151        bg_paint.set_style(PaintStyle::Fill);
152        bg_paint.set_anti_alias(true);
153        canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, h), &bg_paint);
154
155        // Draw world map dots
156        if self.show_world {
157            let mut world_paint = paint_from_hex(&self.world_dot_color);
158            world_paint.set_style(PaintStyle::Fill);
159            world_paint.set_anti_alias(true);
160
161            // A spacing at or below zero makes the division +inf, and `inf as u32`
162            // saturates to u32::MAX in Rust — the nested loop below would then be
163            // scheduled for ~1.8e19 iterations and never return. The geometry pass
164            // rejects 0.01 but not 0, so the floor has to live here.
165            let spacing = if self.dot_spacing.is_finite() && self.dot_spacing >= 1.0 {
166                self.dot_spacing
167            } else {
168                1.0
169            };
170            let radius = self.dot_radius;
171            let margin = spacing;
172
173            // Belt and braces: even a legal spacing on a very large box should not
174            // be able to schedule an unbounded amount of work.
175            const MAX_DOTS_PER_AXIS: u32 = 4096;
176            let cols = (((w - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
177            let rows = (((h - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
178
179            for row in 0..rows {
180                for col in 0..cols {
181                    let px = margin + col as f32 * spacing;
182                    let py = margin + row as f32 * spacing;
183
184                    // Normalize to 0-1
185                    let nx = px / w;
186                    let ny = py / h;
187
188                    if is_land_screen(nx, ny) {
189                        canvas.draw_circle((px, py), radius, &world_paint);
190                    }
191                }
192            }
193        }
194
195        // Render data points
196        let default_color = "#3B82F6";
197        let default_dot_size = 10.0_f32;
198        let label_font_size = 12.0_f32;
199        let point_count = self.points.len();
200
201        let font_style = skia_safe::FontStyle::normal();
202        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
203            return;
204        };
205        let label_font = skia_safe::Font::from_typeface(typeface, label_font_size);
206        let emoji_font =
207            emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size));
208
209        for (i, point) in self.points.iter().enumerate() {
210            // Staggered animation
211            let dot_alpha = if self.animated {
212                let stagger_delay = if point_count > 1 {
213                    (i as f64 / point_count as f64) * 0.6
214                } else {
215                    0.0
216                };
217                let dot_progress = ((time - stagger_delay) / (self.animation_duration * 0.4))
218                    .clamp(0.0, 1.0) as f32;
219                dot_progress * progress
220            } else {
221                1.0
222            };
223
224            if dot_alpha <= 0.0 {
225                continue;
226            }
227
228            let (nx, ny) = geo_to_screen(point.lat, point.lng);
229            let px = nx * w;
230            let py = ny * h;
231            let dot_size = point.size.unwrap_or(default_dot_size);
232            let color_str = point.color.as_deref().unwrap_or(default_color);
233
234            // Pulse rings (expanding concentric circles)
235            if point.pulse.unwrap_or(false) {
236                let num_rings = 2;
237                for ring in 0..num_rings {
238                    let phase = ((time * 1.5 + ring as f64 * 0.5).fract()) as f32;
239                    let ring_radius = dot_size * (1.0 + phase * 2.5);
240                    let ring_alpha = (1.0 - phase).max(0.0) * 0.4 * dot_alpha;
241
242                    let mut pulse_paint = paint_from_hex(color_str);
243                    pulse_paint.set_style(PaintStyle::Stroke);
244                    pulse_paint.set_stroke_width(2.0);
245                    pulse_paint.set_anti_alias(true);
246                    pulse_paint.set_alpha_f(ring_alpha);
247                    canvas.draw_circle((px, py), ring_radius, &pulse_paint);
248                }
249            }
250
251            // Filled dot
252            let mut dot_paint = paint_from_hex(color_str);
253            dot_paint.set_style(PaintStyle::Fill);
254            dot_paint.set_anti_alias(true);
255            dot_paint.set_alpha_f(dot_alpha);
256            canvas.draw_circle((px, py), dot_size / 2.0, &dot_paint);
257
258            // White border
259            let mut border_paint = paint_from_hex("#FFFFFF");
260            border_paint.set_style(PaintStyle::Stroke);
261            border_paint.set_stroke_width(1.5);
262            border_paint.set_anti_alias(true);
263            border_paint.set_alpha_f(dot_alpha * 0.6);
264            canvas.draw_circle((px, py), dot_size / 2.0, &border_paint);
265
266            // Label
267            if let Some(label) = &point.label {
268                let mut label_paint = paint_from_hex("#FFFFFF");
269                label_paint.set_anti_alias(true);
270                label_paint.set_alpha_f(dot_alpha * 0.9);
271
272                let text_w = measure_text_with_fallback(label, &label_font, &emoji_font, 0.0);
273                let label_x = px - text_w / 2.0;
274                let label_y = py + dot_size / 2.0 + label_font_size + 4.0;
275
276                draw_text_with_fallback(
277                    canvas,
278                    label,
279                    &label_font,
280                    &emoji_font,
281                    0.0,
282                    label_x,
283                    label_y,
284                    &label_paint,
285                );
286            }
287        }
288    }
289}
290
291impl Painter for DotMap {
292    fn paint_content(
293        &self,
294        canvas: &Canvas,
295        layout: &BoxLayout,
296        _props: &AnimatedProperties,
297        ctx: &PaintCtx,
298    ) {
299        self.paint(canvas, layout.width, layout.height, ctx.time);
300    }
301}