Files
Marcos Paulo aeac86ddd2
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Bases model, evaluator, function library and map data model
.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>
2026-10-21 02:58:07 -03:00

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}"));
}