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        SphericalPoint::new_unchecked(self.radius, theta, phi)
27    }
28
29    fn compute_quality(entries: &[LayoutEntry<impl Clone>], n: usize) -> LayoutQuality {
30        if n <= 1 {
31            return LayoutQuality {
32                dispersion_score: 1.0,
33                overlap_score: 0.0,
34                silhouette_score: 0.0,
35            };
36        }
37
38        let ideal_spacing = (4.0 * PI / n as f64).sqrt();
39
40        // Extracting the positions up front lets the parallel scan
41        // share a `&[SphericalPoint]` (Copy + Sync) without forcing the
42        // generic `T` to be `Sync`. The pair scan is O(n²) and
43        // embarrassingly parallel — each outer index reduces to a
44        // local min over its tail. Stay serial under SERIAL_THRESHOLD
45        // so small layouts don't pay the thread-pool startup cost.
46        use rayon::prelude::*;
47        const SERIAL_THRESHOLD: usize = 128;
48
49        let positions: Vec<SphericalPoint> = entries.iter().map(|e| e.position).collect();
50
51        let min_dist = if n < SERIAL_THRESHOLD {
52            let mut m = f64::MAX;
53            for i in 0..n {
54                for j in (i + 1)..n {
55                    let d = angular_distance(&positions[i], &positions[j]);
56                    if d < m {
57                        m = d;
58                    }
59                }
60            }
61            m
62        } else {
63            (0..n)
64                .into_par_iter()
65                .map(|i| {
66                    let mut m = f64::MAX;
67                    for j in (i + 1)..n {
68                        let d = angular_distance(&positions[i], &positions[j]);
69                        if d < m {
70                            m = d;
71                        }
72                    }
73                    m
74                })
75                .reduce(|| f64::MAX, f64::min)
76        };
77
78        let dispersion = (min_dist / ideal_spacing).clamp(0.0, 1.0);
79
80        LayoutQuality {
81            dispersion_score: dispersion,
82            overlap_score: 0.0,
83            silhouette_score: 0.0,
84        }
85    }
86}
87
88impl Default for UniformLayout {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl<T: Clone> LayoutStrategy<T> for UniformLayout {
95    fn layout(&self, items: &[T], mapper: &dyn DimensionMapper<Item = T>) -> LayoutResult<T> {
96        let n = items.len();
97
98        if n == 0 {
99            return LayoutResult {
100                entries: Vec::new(),
101                quality: LayoutQuality::default(),
102            };
103        }
104
105        // UniformLayout places items on a Fibonacci lattice; the mapper's
106        // semantic positions are intentionally ignored.
107        let _ = mapper;
108
109        let entries: Vec<LayoutEntry<T>> = items
110            .iter()
111            .enumerate()
112            .map(|(i, item)| LayoutEntry {
113                item: item.clone(),
114                position: self.fibonacci_point(i, n),
115            })
116            .collect();
117
118        let quality = Self::compute_quality(&entries, n);
119
120        LayoutResult { entries, quality }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    struct IdentityMapper;
128
129    impl DimensionMapper for IdentityMapper {
130        type Item = u32;
131        fn map(&self, _item: &u32) -> SphericalPoint {
132            SphericalPoint::origin()
133        }
134    }
135
136    #[test]
137    fn empty_items_returns_empty() {
138        let layout = UniformLayout::new();
139        let items: Vec<u32> = vec![];
140        let result = layout.layout(&items, &IdentityMapper);
141        assert!(result.entries.is_empty());
142    }
143
144    #[test]
145    fn single_item_correct_position() {
146        let layout = UniformLayout::new();
147        let result = layout.layout(&[42u32], &IdentityMapper);
148        assert_eq!(result.entries.len(), 1);
149        let pos = &result.entries[0].position;
150        // For n=1, i=0: phi = acos(1.0 - 2.0*0.5/1.0) = acos(0.0) = PI/2
151        assert!((pos.phi - std::f64::consts::FRAC_PI_2).abs() < 1e-10);
152        assert!((pos.r - 1.0).abs() < 1e-10);
153        // theta = (golden_angle * 0) % TAU = 0.0
154        assert!(pos.theta.abs() < 1e-10);
155    }
156
157    #[test]
158    fn n_items_all_distinct_positions() {
159        let layout = UniformLayout::new();
160        let items: Vec<u32> = (0..20).collect();
161        let result = layout.layout(&items, &IdentityMapper);
162        assert_eq!(result.entries.len(), 20);
163
164        // Verify all positions are distinct by checking no two points are too close
165        for i in 0..20 {
166            for j in (i + 1)..20 {
167                let d = angular_distance(&result.entries[i].position, &result.entries[j].position);
168                assert!(d > 1e-10, "points {i} and {j} are not distinct");
169            }
170        }
171    }
172
173    #[test]
174    fn all_positions_have_configured_radius() {
175        let r = 2.5;
176        let layout = UniformLayout::with_radius(r);
177        let items: Vec<u32> = (0..50).collect();
178        let result = layout.layout(&items, &IdentityMapper);
179        for (i, entry) in result.entries.iter().enumerate() {
180            assert!(
181                (entry.position.r - r).abs() < 1e-12,
182                "entry {i} has radius {}, expected {r}",
183                entry.position.r
184            );
185        }
186    }
187
188    #[test]
189    fn dispersion_score_reasonable_for_100_items() {
190        let layout = UniformLayout::new();
191        let items: Vec<u32> = (0..100).collect();
192        let result = layout.layout(&items, &IdentityMapper);
193        assert!(
194            result.quality.dispersion_score > 0.5,
195            "dispersion score {} should be > 0.5 for 100 items",
196            result.quality.dispersion_score,
197        );
198    }
199
200    #[test]
201    fn fibonacci_spiral_good_coverage() {
202        let layout = UniformLayout::new();
203        let items: Vec<u32> = (0..200).collect();
204        let result = layout.layout(&items, &IdentityMapper);
205
206        let ideal_spacing = (4.0 * PI / 200.0).sqrt();
207        let mut min_dist = f64::MAX;
208        for i in 0..result.entries.len() {
209            for j in (i + 1)..result.entries.len() {
210                let d = angular_distance(&result.entries[i].position, &result.entries[j].position);
211                if d < min_dist {
212                    min_dist = d;
213                }
214            }
215        }
216
217        // For a Fibonacci spiral, min distance should be a reasonable fraction of ideal
218        assert!(
219            min_dist > ideal_spacing * 0.4,
220            "min angular distance {min_dist} is too small relative to ideal {ideal_spacing}",
221        );
222    }
223
224    #[test]
225    fn overlap_and_silhouette_are_zero() {
226        let layout = UniformLayout::new();
227        let items: Vec<u32> = (0..10).collect();
228        let result = layout.layout(&items, &IdentityMapper);
229        assert!((result.quality.overlap_score).abs() < 1e-12);
230        assert!((result.quality.silhouette_score).abs() < 1e-12);
231    }
232
233    #[test]
234    fn default_radius_is_one() {
235        let layout = UniformLayout::default();
236        let result = layout.layout(&[1u32], &IdentityMapper);
237        assert!((result.entries[0].position.r - 1.0).abs() < 1e-12);
238    }
239}