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#[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 = 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}