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#[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#[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#[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#[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 #[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
130fn 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 pub lat: f64,
193 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 #[serde(default = "default_dot_color")]
237 pub world_dot_color: String,
238 #[serde(default = "default_dot_spacing")]
240 pub dot_spacing: f32,
241 #[serde(default = "default_dot_radius")]
243 pub dot_radius: f32,
244 #[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 #[serde(default)]
255 pub projection: DotMapProjection,
256 #[serde(default)]
259 pub rotate: Option<GlobeRotation>,
260 #[serde(default)]
263 pub limb_shading: Option<f32>,
264 #[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}