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 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 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 assert!((pos.phi - std::f64::consts::FRAC_PI_2).abs() < 1e-10);
126 assert!((pos.r - 1.0).abs() < 1e-10);
127 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 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 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}