sphereql_layout/
uniform.rs1use 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); pub 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 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 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 assert!((pos.phi - std::f64::consts::FRAC_PI_2).abs() < 1e-10);
152 assert!((pos.r - 1.0).abs() < 1e-10);
153 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 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 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}