Skip to main content

sphereql_layout/
uniform.rs

1use std::f64::consts::{PI, TAU};
2
3use sphereql_core::{SphericalPoint, angular_distance};
4
5use crate::traits::{DimensionMapper, LayoutStrategy};
6use crate::types::{LayoutEntry, LayoutQuality, LayoutResult};
7
8const GOLDEN_ANGLE: f64 = PI * (3.0 - 2.236_067_977_499_79); // PI * (3 - sqrt(5)) ≈ 2.3999 rad
9
10pub struct UniformLayout {
11    radius: f64,
12}
13
14impl UniformLayout {
15    pub fn new() -> Self {
16        Self { radius: 1.0 }
17    }
18
19    pub fn with_radius(radius: f64) -> Self {
20        Self { radius }
21    }
22
23    fn fibonacci_point(&self, i: usize, n: usize) -> SphericalPoint {
24        let phi = (1.0 - 2.0 * (i as f64 + 0.5) / n as f64).acos();
25        let theta = (GOLDEN_ANGLE * i as f64) % TAU;
26        // Ensure theta is non-negative after modulo
27        let theta = if theta < 0.0 { theta + TAU } else { theta };
28        SphericalPoint::new_unchecked(self.radius, theta, phi)
29    }
30
31    fn compute_quality(entries: &[LayoutEntry<impl Clone>], n: usize) -> LayoutQuality {
32        if n <= 1 {
33            return LayoutQuality {
34                dispersion_score: 1.0,
35                overlap_score: 0.0,
36                silhouette_score: 0.0,
37            };
38        }
39
40        let ideal_spacing = (4.0 * PI / n as f64).sqrt();
41
42        let mut min_dist = f64::MAX;
43        for i in 0..n {
44            for j in (i + 1)..n {
45                let d = angular_distance(&entries[i].position, &entries[j].position);
46                if d < min_dist {
47                    min_dist = d;
48                }
49            }
50        }
51
52        let dispersion = (min_dist / ideal_spacing).clamp(0.0, 1.0);
53
54        LayoutQuality {
55            dispersion_score: dispersion,
56            overlap_score: 0.0,
57            silhouette_score: 0.0,
58        }
59    }
60}
61
62impl Default for UniformLayout {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl<T: Clone> LayoutStrategy<T> for UniformLayout {
69    fn layout(&self, items: &[T], mapper: &dyn DimensionMapper<Item = T>) -> LayoutResult<T> {
70        let n = items.len();
71
72        if n == 0 {
73            return LayoutResult {
74                entries: Vec::new(),
75                quality: LayoutQuality::default(),
76            };
77        }
78
79        // UniformLayout places items on a Fibonacci lattice; the mapper's
80        // semantic positions are intentionally ignored.
81        let _ = mapper;
82
83        let entries: Vec<LayoutEntry<T>> = items
84            .iter()
85            .enumerate()
86            .map(|(i, item)| LayoutEntry {
87                item: item.clone(),
88                position: self.fibonacci_point(i, n),
89            })
90            .collect();
91
92        let quality = Self::compute_quality(&entries, n);
93
94        LayoutResult { entries, quality }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    struct IdentityMapper;
102
103    impl DimensionMapper for IdentityMapper {
104        type Item = u32;
105        fn map(&self, _item: &u32) -> SphericalPoint {
106            SphericalPoint::origin()
107        }
108    }
109
110    #[test]
111    fn empty_items_returns_empty() {
112        let layout = UniformLayout::new();
113        let items: Vec<u32> = vec![];
114        let result = layout.layout(&items, &IdentityMapper);
115        assert!(result.entries.is_empty());
116    }
117
118    #[test]
119    fn single_item_correct_position() {
120        let layout = UniformLayout::new();
121        let result = layout.layout(&[42u32], &IdentityMapper);
122        assert_eq!(result.entries.len(), 1);
123        let pos = &result.entries[0].position;
124        // For n=1, i=0: phi = acos(1.0 - 2.0*0.5/1.0) = acos(0.0) = PI/2
125        assert!((pos.phi - std::f64::consts::FRAC_PI_2).abs() < 1e-10);
126        assert!((pos.r - 1.0).abs() < 1e-10);
127        // theta = (golden_angle * 0) % TAU = 0.0
128        assert!(pos.theta.abs() < 1e-10);
129    }
130
131    #[test]
132    fn n_items_all_distinct_positions() {
133        let layout = UniformLayout::new();
134        let items: Vec<u32> = (0..20).collect();
135        let result = layout.layout(&items, &IdentityMapper);
136        assert_eq!(result.entries.len(), 20);
137
138        // Verify all positions are distinct by checking no two points are too close
139        for i in 0..20 {
140            for j in (i + 1)..20 {
141                let d = angular_distance(&result.entries[i].position, &result.entries[j].position);
142                assert!(d > 1e-10, "points {i} and {j} are not distinct");
143            }
144        }
145    }
146
147    #[test]
148    fn all_positions_have_configured_radius() {
149        let r = 2.5;
150        let layout = UniformLayout::with_radius(r);
151        let items: Vec<u32> = (0..50).collect();
152        let result = layout.layout(&items, &IdentityMapper);
153        for (i, entry) in result.entries.iter().enumerate() {
154            assert!(
155                (entry.position.r - r).abs() < 1e-12,
156                "entry {i} has radius {}, expected {r}",
157                entry.position.r
158            );
159        }
160    }
161
162    #[test]
163    fn dispersion_score_reasonable_for_100_items() {
164        let layout = UniformLayout::new();
165        let items: Vec<u32> = (0..100).collect();
166        let result = layout.layout(&items, &IdentityMapper);
167        assert!(
168            result.quality.dispersion_score > 0.5,
169            "dispersion score {} should be > 0.5 for 100 items",
170            result.quality.dispersion_score,
171        );
172    }
173
174    #[test]
175    fn fibonacci_spiral_good_coverage() {
176        let layout = UniformLayout::new();
177        let items: Vec<u32> = (0..200).collect();
178        let result = layout.layout(&items, &IdentityMapper);
179
180        let ideal_spacing = (4.0 * PI / 200.0).sqrt();
181        let mut min_dist = f64::MAX;
182        for i in 0..result.entries.len() {
183            for j in (i + 1)..result.entries.len() {
184                let d = angular_distance(&result.entries[i].position, &result.entries[j].position);
185                if d < min_dist {
186                    min_dist = d;
187                }
188            }
189        }
190
191        // For a Fibonacci spiral, min distance should be a reasonable fraction of ideal
192        assert!(
193            min_dist > ideal_spacing * 0.4,
194            "min angular distance {min_dist} is too small relative to ideal {ideal_spacing}",
195        );
196    }
197
198    #[test]
199    fn overlap_and_silhouette_are_zero() {
200        let layout = UniformLayout::new();
201        let items: Vec<u32> = (0..10).collect();
202        let result = layout.layout(&items, &IdentityMapper);
203        assert!((result.quality.overlap_score).abs() < 1e-12);
204        assert!((result.quality.silhouette_score).abs() < 1e-12);
205    }
206
207    #[test]
208    fn default_radius_is_one() {
209        let layout = UniformLayout::default();
210        let result = layout.layout(&[1u32], &IdentityMapper);
211        assert!((result.entries[0].position.r - 1.0).abs() < 1e-12);
212    }
213}