aeac86ddd2
.base model (filters, formulas, properties, views; unknown keys kept in order; limits and invalid-value errors), evaluator with the D5 type rules, lazy if and boolean operators, memoised formulas with cycle errors, depth cap 200, string/number/object/list/date/link/file/regexp functions with lambdas (regex through notescraft-regex), and the map model (Web Mercator, tile ids, viewport tiles, grid clustering, coordinate and colour parsing, OSM attribution constants; Paris z10 is tile 518/352). Bases tasks 2, 5, 6 (Claude Sonnet) and 8 (MiniMax M3.1 Flash, OpenCode Zen free route). Verified: notescraft-bases suites all pass, clippy -D warnings, xtask layers and tree clean. Known gaps: toFixed rounds ties to even, list.unique is quadratic. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
225 lines
8.3 KiB
Rust
225 lines
8.3 KiB
Rust
//! 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<u32> = 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<Marker> =
|
|
(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<usize> = clusters.iter().flat_map(|group| group.ids.clone()).collect();
|
|
identifiers.sort_unstable();
|
|
assert_eq!(identifiers, (0..360).collect::<Vec<usize>>());
|
|
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}"));
|
|
}
|