Skip to main content

sphereql_layout/
managed.rs

1use std::collections::HashSet;
2
3use sphereql_core::SphericalPoint;
4
5use crate::traits::{DimensionMapper, LayoutStrategy};
6use crate::types::LayoutEntry;
7
8/// An incrementally updated layout that tracks which items have changed.
9///
10/// Supports add / remove / mark-dirty operations with two reflow modes:
11/// [`Self::reflow`] (full) and [`Self::reflow_incremental`] (only dirty items).
12pub struct ManagedLayout<T> {
13    items: Vec<T>,
14    positions: Vec<SphericalPoint>,
15    dirty: HashSet<usize>,
16    needs_full_reflow: bool,
17}
18
19impl<T: Clone + Send + Sync> ManagedLayout<T> {
20    pub fn new() -> Self {
21        Self {
22            items: Vec::new(),
23            positions: Vec::new(),
24            dirty: HashSet::new(),
25            needs_full_reflow: false,
26        }
27    }
28
29    pub fn add(&mut self, item: T) {
30        let idx = self.items.len();
31        self.items.push(item);
32        self.positions.push(SphericalPoint::origin());
33        self.dirty.insert(idx);
34    }
35
36    pub fn remove(&mut self, index: usize) -> Option<T> {
37        if index >= self.items.len() {
38            return None;
39        }
40
41        let item = self.items.remove(index);
42        let _ = self.positions.remove(index);
43        self.dirty.remove(&index);
44
45        if index != self.items.len() {
46            self.needs_full_reflow = true;
47            let shifted: HashSet<usize> = self
48                .dirty
49                .iter()
50                .map(|&i| if i > index { i - 1 } else { i })
51                .collect();
52            self.dirty = shifted;
53        }
54        self.dirty.retain(|&i| i < self.items.len());
55
56        Some(item)
57    }
58
59    pub fn mark_dirty(&mut self, index: usize) {
60        if index < self.items.len() {
61            self.dirty.insert(index);
62        }
63    }
64
65    pub fn reflow(
66        &mut self,
67        strategy: &dyn LayoutStrategy<T>,
68        mapper: &dyn DimensionMapper<Item = T>,
69    ) {
70        let result = strategy.layout(&self.items, mapper);
71        debug_assert_eq!(
72            result.entries.len(),
73            self.positions.len(),
74            "LayoutStrategy must return one entry per item"
75        );
76        for (i, entry) in result.entries.into_iter().enumerate() {
77            if i < self.positions.len() {
78                self.positions[i] = entry.position;
79            }
80        }
81        self.dirty.clear();
82        self.needs_full_reflow = false;
83    }
84
85    pub fn reflow_incremental(
86        &mut self,
87        strategy: &dyn LayoutStrategy<T>,
88        mapper: &dyn DimensionMapper<Item = T>,
89    ) {
90        if self.needs_full_reflow {
91            self.reflow(strategy, mapper);
92            return;
93        }
94
95        if self.dirty.is_empty() {
96            return;
97        }
98
99        let dirty_indices: Vec<usize> = self.dirty.iter().copied().collect();
100
101        // Pass the full item set so global strategies (e.g. force-directed)
102        // can see all neighbors when placing dirty nodes — but only commit
103        // position updates for the dirty subset to preserve incremental
104        // semantics.
105        let result = strategy.layout(&self.items, mapper);
106        debug_assert_eq!(
107            result.entries.len(),
108            self.items.len(),
109            "layout strategy returned {} entries for {} items",
110            result.entries.len(),
111            self.items.len()
112        );
113        for &i in &dirty_indices {
114            if let Some(entry) = result.entries.get(i) {
115                self.positions[i] = entry.position;
116            }
117        }
118
119        self.dirty.clear();
120    }
121
122    pub fn items(&self) -> &[T] {
123        &self.items
124    }
125
126    pub fn positions(&self) -> &[SphericalPoint] {
127        &self.positions
128    }
129
130    pub fn len(&self) -> usize {
131        self.items.len()
132    }
133
134    pub fn is_empty(&self) -> bool {
135        self.items.is_empty()
136    }
137
138    pub fn get_entry(&self, index: usize) -> Option<LayoutEntry<&T>> {
139        if index >= self.items.len() {
140            return None;
141        }
142        Some(LayoutEntry {
143            item: &self.items[index],
144            position: self.positions[index],
145        })
146    }
147}
148
149impl<T: Clone + Send + Sync> Default for ManagedLayout<T> {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::traits::DimensionMapper;
159    use crate::types::{LayoutQuality, LayoutResult};
160    use std::f64::consts::FRAC_PI_2;
161
162    struct IdentityMapper;
163
164    impl DimensionMapper for IdentityMapper {
165        type Item = u32;
166        fn map(&self, _item: &u32) -> SphericalPoint {
167            SphericalPoint::origin()
168        }
169    }
170
171    struct DeterministicStrategy;
172
173    impl LayoutStrategy<u32> for DeterministicStrategy {
174        fn layout(
175            &self,
176            items: &[u32],
177            _mapper: &dyn DimensionMapper<Item = u32>,
178        ) -> LayoutResult<u32> {
179            let entries = items
180                .iter()
181                .map(|&item| LayoutEntry {
182                    item,
183                    position: SphericalPoint::new_unchecked(1.0, item as f64 * 0.1, FRAC_PI_2),
184                })
185                .collect();
186            LayoutResult {
187                entries,
188                quality: LayoutQuality::default(),
189            }
190        }
191    }
192
193    #[test]
194    fn new_layout_is_empty() {
195        let layout: ManagedLayout<u32> = ManagedLayout::new();
196        assert!(layout.is_empty());
197        assert_eq!(layout.len(), 0);
198        assert!(layout.items().is_empty());
199        assert!(layout.positions().is_empty());
200    }
201
202    #[test]
203    fn add_increases_len() {
204        let mut layout = ManagedLayout::new();
205        layout.add(1u32);
206        assert_eq!(layout.len(), 1);
207        layout.add(2);
208        layout.add(3);
209        assert_eq!(layout.len(), 3);
210        assert!(!layout.is_empty());
211    }
212
213    #[test]
214    fn remove_returns_item_and_decrements() {
215        let mut layout = ManagedLayout::new();
216        layout.add(10u32);
217        layout.add(20);
218        layout.add(30);
219        assert_eq!(layout.len(), 3);
220
221        let removed = layout.remove(1);
222        assert_eq!(removed, Some(20));
223        assert_eq!(layout.len(), 2);
224        assert_eq!(layout.items(), &[10, 30]);
225
226        let removed = layout.remove(1);
227        assert_eq!(removed, Some(30));
228        assert_eq!(layout.len(), 1);
229
230        assert_eq!(layout.remove(5), None);
231    }
232
233    #[test]
234    fn incremental_reflow_only_updates_dirty() {
235        let mut layout = ManagedLayout::new();
236        layout.add(1u32);
237        layout.add(2);
238        layout.add(3);
239
240        layout.reflow(&DeterministicStrategy, &IdentityMapper);
241        let pos_0_after_full = layout.positions()[0];
242        let pos_2_after_full = layout.positions()[2];
243
244        layout.mark_dirty(1);
245
246        layout.reflow_incremental(&DeterministicStrategy, &IdentityMapper);
247
248        assert_eq!(layout.positions()[0].theta, pos_0_after_full.theta);
249        assert_eq!(layout.positions()[2].theta, pos_2_after_full.theta);
250        assert!(layout.positions()[1].theta.is_finite());
251    }
252
253    #[test]
254    fn full_reflow_updates_all_positions() {
255        let mut layout = ManagedLayout::new();
256        layout.add(5u32);
257        layout.add(10);
258
259        assert_eq!(layout.positions()[0].theta, 0.0);
260        assert_eq!(layout.positions()[1].theta, 0.0);
261
262        layout.reflow(&DeterministicStrategy, &IdentityMapper);
263
264        assert!((layout.positions()[0].theta - 0.5).abs() < 1e-12);
265        assert!((layout.positions()[1].theta - 1.0).abs() < 1e-12);
266    }
267
268    #[test]
269    fn incremental_falls_back_to_full_when_needed() {
270        let mut layout = ManagedLayout::new();
271        layout.add(1u32);
272        layout.add(2);
273        layout.add(3);
274
275        layout.reflow(&DeterministicStrategy, &IdentityMapper);
276
277        layout.remove(0);
278        assert_eq!(layout.items(), &[2, 3]);
279
280        layout.reflow_incremental(&DeterministicStrategy, &IdentityMapper);
281
282        assert!((layout.positions()[0].theta - 0.2).abs() < 1e-12);
283        assert!((layout.positions()[1].theta - 0.3).abs() < 1e-12);
284    }
285
286    #[test]
287    fn get_entry_returns_correct_data() {
288        let mut layout = ManagedLayout::new();
289        layout.add(42u32);
290        layout.reflow(&DeterministicStrategy, &IdentityMapper);
291
292        let entry = layout.get_entry(0).unwrap();
293        assert_eq!(*entry.item, 42);
294        assert!((entry.position.theta - 4.2).abs() < 1e-12);
295
296        assert!(layout.get_entry(1).is_none());
297    }
298}