use crate::gui_system::internal_math::{MAXIMUM_ZOOM_LEVEL, TILE_SIZE_PIXEL, TileCoordinates};
use crate::gui_system::latitude_longitude::LatitudeLongitude;
use crate::gui_system::map_widget::focus::FocalPoint;
use iced::Size;
use iced::time::{Duration, Instant};
const PAN_DURATION: Duration = Duration::from_millis(1000);
const ZOOM_DURATION_PER_LEVEL: Duration = Duration::from_millis(150);
const FIT_MARGIN: f32 = 0.25;
const ZOOM_EPSILON: f32 = 1e-3;
const PAN_EPSILON_PIXEL: f64 = 0.5;
#[derive(Debug, Clone, Copy)]
struct Segment {
from: FocalPoint,
to: FocalPoint,
duration: Duration,
}
impl Segment {
fn sample(&self, fraction: f64) -> FocalPoint {
let eased = fraction * fraction * (3.0 - 2.0 * fraction);
let from = self.from.position.get_tile_coordinates(0);
let to = self.to.position.get_tile_coordinates(0);
let position = TileCoordinates {
x: from.x + (to.x - from.x) * eased,
y: from.y + (to.y - from.y) * eased,
zoom: 0,
}
.into();
let zoom = self.from.continuous_zoom_level as f64
+ (self.to.continuous_zoom_level - self.from.continuous_zoom_level) as f64 * eased;
FocalPoint {
position,
continuous_zoom_level: zoom as f32,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct FocusAnimation {
start: Instant,
segments: Vec<Segment>,
target: FocalPoint,
}
impl FocusAnimation {
pub(crate) fn new(
from: FocalPoint,
target: FocalPoint,
view_size: Size,
start: Instant,
) -> Self {
let lower = FocalPoint {
continuous_zoom_level: from.continuous_zoom_level.min(target.continuous_zoom_level),
..from
};
let pan_level = zoom_out_level(&lower, &target.position, view_size)
.unwrap_or(lower.continuous_zoom_level);
let out = FocalPoint {
continuous_zoom_level: pan_level,
..from
};
let over = FocalPoint {
continuous_zoom_level: pan_level,
..target
};
let zoom = |from: FocalPoint, to: FocalPoint| {
let levels = (from.continuous_zoom_level - to.continuous_zoom_level).abs();
(levels > ZOOM_EPSILON).then(|| Segment {
from,
to,
duration: ZOOM_DURATION_PER_LEVEL.mul_f32(levels),
})
};
let (dx, dy) = pixel_offset(&out, &target.position);
let pan = (dx.hypot(dy) > PAN_EPSILON_PIXEL).then_some(Segment {
from: out,
to: over,
duration: PAN_DURATION,
});
let segments = [zoom(from, out), pan, zoom(over, target)]
.into_iter()
.flatten()
.collect();
Self {
start,
segments,
target,
}
}
pub(crate) fn sample(&self, now: Instant) -> (FocalPoint, bool) {
let mut elapsed = now.saturating_duration_since(self.start);
for segment in &self.segments {
if elapsed < segment.duration {
let fraction = elapsed.as_secs_f64() / segment.duration.as_secs_f64();
return (segment.sample(fraction), false);
}
elapsed -= segment.duration;
}
(self.target, true)
}
}
pub(super) fn pixel_offset(from: &FocalPoint, target: &LatitudeLongitude) -> (f64, f64) {
let scale = TILE_SIZE_PIXEL as f64 * f64::exp2(from.continuous_zoom_level as f64);
let a = from.position.get_tile_coordinates(0);
let b = target.get_tile_coordinates(0);
((b.x - a.x) * scale, (b.y - a.y) * scale)
}
fn zoom_out_level(from: &FocalPoint, target: &LatitudeLongitude, view_size: Size) -> Option<f32> {
let (dx, dy) = pixel_offset(from, target);
let (width, height) = (view_size.width as f64, view_size.height as f64);
if dx.abs() <= width && dy.abs() <= height {
return None;
}
let level = |distance: f64, half_extent: f64| {
if distance <= half_extent {
f64::INFINITY
} else {
from.continuous_zoom_level as f64 - f64::log2(distance / half_extent)
}
};
let zoom = level(dx.abs(), width * 0.5).min(level(dy.abs(), height * 0.5)) as f32 - FIT_MARGIN;
Some(zoom.clamp(0.0, MAXIMUM_ZOOM_LEVEL as f32))
}
#[cfg(test)]
mod tests {
use super::*;
fn trier() -> FocalPoint {
FocalPoint {
position: LatitudeLongitude::new(49.75, 6.63),
continuous_zoom_level: 12.0,
}
}
const VIEW: Size = Size {
width: 594.0,
height: 492.0,
};
#[test]
fn mertert_is_a_pan() {
let mertert = LatitudeLongitude::new(49.7031, 6.4797);
let (dx, _) = pixel_offset(&trier(), &mertert);
assert!(dx.abs() > VIEW.width as f64 * 0.5);
assert!(zoom_out_level(&trier(), &mertert, VIEW).is_none());
}
#[test]
fn montreal_zooms_out_far() {
let montreal = LatitudeLongitude::new(45.5017, -73.5673);
let zoom = zoom_out_level(&trier(), &montreal, VIEW).expect("too far for a pan");
assert!(zoom < 4.0, "zoom out level {zoom}");
let out = FocalPoint {
continuous_zoom_level: zoom,
..trier()
};
let (dx, dy) = pixel_offset(&out, &montreal);
assert!(dx.abs() < VIEW.width as f64 * 0.5);
assert!(dy.abs() < VIEW.height as f64 * 0.5);
}
#[test]
fn animation_ends_on_target_at_start_zoom() {
let koeln = LatitudeLongitude::new(50.9375, 6.9603);
let start = Instant::now();
let target = FocalPoint {
position: koeln,
..trier()
};
let animation = FocusAnimation::new(trier(), target, VIEW, start);
assert_eq!(animation.segments.len(), 3);
let (begin, finished) = animation.sample(start);
assert!(!finished);
assert!((begin.position.latitude - 49.75).abs() < 1e-9);
let (end, finished) = animation.sample(start + Duration::from_secs(60));
assert!(finished);
assert!((end.position.latitude - koeln.latitude).abs() < 1e-9);
assert!((end.position.longitude - koeln.longitude).abs() < 1e-9);
assert_eq!(end.continuous_zoom_level, 12.0);
}
#[test]
fn zoom_in_on_the_spot_has_no_pan() {
let start = Instant::now();
let target = FocalPoint {
continuous_zoom_level: 15.0,
..trier()
};
let animation = FocusAnimation::new(trier(), target, VIEW, start);
assert_eq!(animation.segments.len(), 1);
let (end, finished) = animation.sample(start + Duration::from_secs(60));
assert!(finished);
assert_eq!(end.continuous_zoom_level, 15.0);
}
#[test]
fn close_area_pans_then_zooms_in() {
let mertert = FocalPoint {
position: LatitudeLongitude::new(49.7031, 6.4797),
continuous_zoom_level: 15.0,
};
let animation = FocusAnimation::new(trier(), mertert, VIEW, Instant::now());
assert_eq!(animation.segments.len(), 2);
assert_eq!(animation.segments[0].to.continuous_zoom_level, 12.0);
assert_eq!(animation.segments[1].to.continuous_zoom_level, 15.0);
}
#[test]
fn far_area_zooms_out_below_both_levels() {
let montreal = FocalPoint {
position: LatitudeLongitude::new(45.5017, -73.5673),
continuous_zoom_level: 8.0,
};
let start = Instant::now();
let animation = FocusAnimation::new(trier(), montreal, VIEW, start);
assert_eq!(animation.segments.len(), 3);
let pan_level = animation.segments[1].from.continuous_zoom_level;
assert!(pan_level < 8.0, "pan level {pan_level}");
let (end, finished) = animation.sample(start + Duration::from_secs(60));
assert!(finished);
assert_eq!(end.continuous_zoom_level, 8.0);
}
}