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 pub lat: f64,
47 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
59const LAT_MAX: f64 = 85.0; const LAT_MIN: f64 = -85.0; fn 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
70fn 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
77fn 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 #[serde(default = "default_dot_color")]
95 pub world_dot_color: String,
96 #[serde(default = "default_dot_spacing")]
98 pub dot_spacing: f32,
99 #[serde(default = "default_dot_radius")]
101 pub dot_radius: f32,
102 #[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
125fn 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 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 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 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 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 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 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 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 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 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 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 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 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}