//! The map data model: projection, tiles, clustering, colours and coordinates. use std::time::Instant; use notescraft_bases::Value; use notescraft_bases::map::{ Attribution, Color, LatLng, Marker, cluster, fit, parse_color, parse_coordinates, project, skipped, tile_of, tiles_for_viewport, unproject, valid, }; /// A small pseudo-random generator, so the tests do not need a dependency. struct Xorshift(u64); impl Xorshift { fn new(seed: u64) -> Self { Self(seed) } fn next_u32(&mut self) -> u32 { let mut state = self.0; state ^= state << 13; state ^= state >> 7; state ^= state << 17; self.0 = state; u32::try_from(state >> 32).unwrap_or(u32::MAX) } fn next_f64(&mut self) -> f64 { f64::from(self.next_u32()) / 4_294_967_296.0 } } const PARIS: LatLng = LatLng { lat: 48.858_37, lng: 2.294_481 }; #[test] fn paris_is_tile_518_352_at_zoom_ten() { let tile = tile_of(PARIS, 10); assert_eq!(tile.z, 10); assert_eq!(tile.x, 518); assert_eq!(tile.y, 352); } #[test] fn paris_is_on_the_known_tiles_of_the_other_zooms() { let whole_world = tile_of(PARIS, 0); assert_eq!((whole_world.x, whole_world.y), (0, 0)); let streets = tile_of(PARIS, 18); assert_eq!((streets.x, streets.y), (132_742, 90_183)); } #[test] fn project_and_unproject_round_trip() { let mut random = Xorshift::new(0x5eed); for zoom in [0u8, 1, 5, 10, 15, 18, 22] { for _ in 0..30 { let lat = (random.next_f64() * 170.0) - 85.0; let lng = (random.next_f64() * 360.0) - 180.0; let point = LatLng { lat, lng }; let (x, y) = project(point, zoom); let back = unproject(x, y, zoom); assert!((back.lat - point.lat).abs() < 1e-9, "lat {lat} at zoom {zoom} came back as {}", back.lat); assert!((back.lng - point.lng).abs() < 1e-9, "lng {lng} at zoom {zoom} came back as {}", back.lng); } } } #[test] fn invalid_positions_are_rejected() { assert!(!valid(LatLng { lat: 86.0, lng: 0.0 })); assert!(!valid(LatLng { lat: 0.0, lng: 180.5 })); assert!(!valid(LatLng { lat: f64::NAN, lng: 0.0 })); assert!(!valid(LatLng { lat: 0.0, lng: f64::INFINITY })); assert!(valid(LatLng { lat: 85.0, lng: 180.0 })); } #[test] fn the_viewport_wraps_around_the_antimeridian() { let tiles = tiles_for_viewport(LatLng { lat: 0.0, lng: 179.9 }, 2, 1024.0, 512.0); assert_ne!(tiles.len(), 0); assert!(tiles.len() <= 256); let columns: Vec = tiles.iter().map(|tile| tile.x).collect(); assert!(columns.contains(&0), "the wrapped column is missing: {columns:?}"); assert!(columns.contains(&3), "the last column is missing: {columns:?}"); for tile in &tiles { assert_eq!(tile.z, 2); assert!(tile.x < 4); assert!(tile.y < 4); } } #[test] fn the_viewport_holds_the_tile_of_its_centre() { let tiles = tiles_for_viewport(PARIS, 10, 512.0, 512.0); assert!(tiles.contains(&tile_of(PARIS, 10)), "the tile of the centre is missing: {tiles:?}"); for tile in &tiles { assert_eq!(tile.z, 10); } let single = tiles_for_viewport(PARIS, 10, 0.0, 0.0); assert_eq!(single, vec![tile_of(PARIS, 10)]); } #[test] fn clustering_gathers_the_markers_and_keeps_every_one() { let mut random = Xorshift::new(0x00c0_ffee); let markers: Vec = (0..360).map(|id| Marker { id, at: LatLng { lat: (random.next_f64() * 160.0) - 80.0, lng: (random.next_f64() * 360.0) - 180.0 } }).collect(); let started = Instant::now(); let clusters = cluster(&markers, 8, 40.0); let elapsed = started.elapsed(); assert!(elapsed.as_millis() < 50, "the clustering took {elapsed:?}"); let counted: usize = clusters.iter().map(|group| group.ids.len()).sum(); assert_eq!(counted, markers.len()); let mut identifiers: Vec = clusters.iter().flat_map(|group| group.ids.clone()).collect(); identifiers.sort_unstable(); assert_eq!(identifiers, (0..360).collect::>()); for group in &clusters { assert_ne!(group.ids.len(), 0); } } #[test] fn clustering_of_one_marker_gives_one_cluster() { let clusters = cluster(&[Marker { id: 7, at: PARIS }], 10, 40.0); assert_eq!(clusters.len(), 1); assert_eq!(clusters[0].ids, vec![7]); assert!((clusters[0].center.lat - PARIS.lat).abs() < 1e-6); assert!((clusters[0].center.lng - PARIS.lng).abs() < 1e-6); } #[test] fn fit_shows_two_points_together() { let points = [LatLng { lat: 48.85, lng: 2.29 }, LatLng { lat: 48.86, lng: 2.30 }]; let width = 800.0; let height = 600.0; let padding = 20.0; let (centre, zoom) = fit(&points, width, height, padding).expect("two points fit"); assert!((centre.lat - 48.855).abs() < 1e-9); assert!((centre.lng - 2.295).abs() < 1e-9); assert!(zoom <= 18, "the zoom {zoom} is above the limit"); let (span_x, span_y) = span_at(&points, zoom); assert!(span_x <= width - 2.0 * padding, "the points do not fit sideways at zoom {zoom}"); assert!(span_y <= height - 2.0 * padding, "the points do not fit upright at zoom {zoom}"); if zoom < 18 { let (wider_x, wider_y) = span_at(&points, zoom + 1); assert!(wider_x > width - 2.0 * padding || wider_y > height - 2.0 * padding, "zoom {} fits as well as {zoom}", zoom + 1); } } #[test] fn fit_of_one_point_uses_zoom_fifteen() { let (centre, zoom) = fit(&[PARIS], 800.0, 600.0, 20.0).expect("one point fits"); assert_eq!(zoom, 15); assert!((centre.lat - PARIS.lat).abs() < 1e-12); assert!((centre.lng - PARIS.lng).abs() < 1e-12); } #[test] fn fit_of_nothing_is_nothing() { assert!(fit(&[], 800.0, 600.0, 20.0).is_none()); } /// The sideways and upright span of the points at a zoom level, in pixels. fn span_at(points: &[LatLng], zoom: u8) -> (f64, f64) { let mut min_x = f64::INFINITY; let mut max_x = f64::NEG_INFINITY; let mut min_y = f64::INFINITY; let mut max_y = f64::NEG_INFINITY; for point in points { let (x, y) = project(*point, zoom); min_x = min_x.min(x); max_x = max_x.max(x); min_y = min_y.min(y); max_y = max_y.max(y); } (max_x - min_x, max_y - min_y) } #[test] fn coordinates_are_read_from_text_and_lists() { let from_text = parse_coordinates(&Value::Str("48.85, 2.29".to_string())).expect("the text holds a position"); assert!((from_text.lat - 48.85).abs() < 1e-12); assert!((from_text.lng - 2.29).abs() < 1e-12); let from_numbers = parse_coordinates(&Value::List(vec![Value::Number(48.85), Value::Number(2.29)])).expect("the list holds a position"); assert!((from_numbers.lat - 48.85).abs() < 1e-12); let from_strings = parse_coordinates(&Value::List(vec![Value::Str("48.85".to_string()), Value::Str("2.29".to_string())])).expect("the list holds a position"); assert!((from_strings.lng - 2.29).abs() < 1e-12); assert!(parse_coordinates(&Value::Str("not a position".to_string())).is_none()); assert!(parse_coordinates(&Value::List(vec![Value::Number(48.85)])).is_none()); assert!(parse_coordinates(&Value::Number(1.0)).is_none()); assert!(parse_coordinates(&Value::Null).is_none()); assert!(parse_coordinates(&Value::Str("86, 2.29".to_string())).is_none()); } #[test] fn skipped_counts_the_positions_that_are_missing() { let points = [Some(PARIS), None, Some(PARIS), None]; assert_eq!(skipped(&points), 2); assert_eq!(skipped(&[]), 0); } #[test] fn colours_are_read_from_css() { assert_eq!(parse_color("#f00"), Some(Color::Rgb(255, 0, 0))); assert_eq!(parse_color("#00FF80"), Some(Color::Rgb(0, 255, 128))); assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Color::Rgb(1, 2, 3))); assert_eq!(parse_color("red"), Some(Color::Rgb(255, 0, 0))); assert_eq!(parse_color("navy"), Some(Color::Rgb(0, 0, 128))); assert_eq!(parse_color("var(--color-accent)"), Some(Color::Token("color-accent".to_string()))); assert!(parse_color("#ff00").is_none()); assert!(parse_color("#gggggg").is_none()); assert!(parse_color("rgb(1, 2)").is_none()); assert!(parse_color("rgb(300, 0, 0)").is_none()); assert!(parse_color("mauve").is_none()); assert!(parse_color("var(--)").is_none()); } #[test] fn the_tiles_need_their_credit() { assert!(Attribution::OSM_ATTRIBUTION.contains("OpenStreetMap")); assert!(Attribution::OSM_TILE_URL.contains("{z}/{x}/{y}")); }