Skip to main content

astrelis_geometry/chart/
cache.rs

1//! Chart caching for improved rendering performance.
2//!
3//! This module provides coordinate caching and spatial indexing to optimize
4//! chart rendering, especially for charts with large data sets.
5
6use super::rect::Rect;
7use super::types::{AxisId, Chart, DataPoint};
8use glam::Vec2;
9
10bitflags::bitflags! {
11    /// Dirty flags for tracking what needs to be updated in a chart.
12    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13    pub struct ChartDirtyFlags: u16 {
14        /// Data has changed completely (requires full rebuild).
15        const DATA_CHANGED = 0b0000_0001;
16        /// Data was appended (can use partial update).
17        const DATA_APPENDED = 0b0000_0010;
18        /// View changed (pan/zoom) - need to recalculate pixel coordinates.
19        const VIEW_CHANGED = 0b0000_0100;
20        /// Style changed (colors, line width, etc.).
21        const STYLE_CHANGED = 0b0000_1000;
22        /// Axes changed (range, ticks, labels).
23        const AXES_CHANGED = 0b0001_0000;
24        /// Bounds changed (widget resized).
25        const BOUNDS_CHANGED = 0b0010_0000;
26    }
27}
28
29impl ChartDirtyFlags {
30    /// Check if the cache needs to be rebuilt.
31    pub fn needs_cache_rebuild(&self) -> bool {
32        self.intersects(
33            Self::DATA_CHANGED | Self::VIEW_CHANGED | Self::AXES_CHANGED | Self::BOUNDS_CHANGED,
34        )
35    }
36
37    /// Check if only data was appended (can use partial update).
38    pub fn is_append_only(&self) -> bool {
39        self.contains(Self::DATA_APPENDED) && !self.contains(Self::DATA_CHANGED)
40    }
41
42    /// Check if only style changed (no geometry update needed).
43    pub fn is_style_only(&self) -> bool {
44        *self == Self::STYLE_CHANGED
45    }
46}
47
48/// Cached pixel coordinates for a single data series.
49#[derive(Debug, Clone)]
50pub struct SeriesPixelCache {
51    /// Pixel positions for each data point.
52    pub positions: Vec<Vec2>,
53    /// X axis ID used for this cache.
54    pub x_axis: AxisId,
55    /// Y axis ID used for this cache.
56    pub y_axis: AxisId,
57    /// Data range that was used to compute these positions.
58    pub x_range: (f64, f64),
59    /// Y data range that was used to compute these positions.
60    pub y_range: (f64, f64),
61    /// Number of data points when cache was built.
62    pub data_count: usize,
63}
64
65impl SeriesPixelCache {
66    /// Create an empty cache.
67    pub fn new(x_axis: AxisId, y_axis: AxisId) -> Self {
68        Self {
69            positions: Vec::new(),
70            x_axis,
71            y_axis,
72            x_range: (0.0, 1.0),
73            y_range: (0.0, 1.0),
74            data_count: 0,
75        }
76    }
77
78    /// Check if the cache is valid for the given parameters.
79    pub fn is_valid(&self, x_range: (f64, f64), y_range: (f64, f64), data_count: usize) -> bool {
80        self.x_range == x_range && self.y_range == y_range && self.data_count == data_count
81    }
82}
83
84/// Chart coordinate and geometry cache.
85///
86/// This cache stores pre-computed pixel coordinates and spatial index
87/// for fast rendering and hit testing.
88#[derive(Debug, Clone)]
89pub struct ChartCache {
90    /// Per-series pixel coordinate caches.
91    series_caches: Vec<SeriesPixelCache>,
92    /// Spatial index for fast hit testing.
93    spatial_index: Option<SpatialIndex>,
94    /// Data version counter (increments when data changes).
95    data_version: u64,
96    /// View version counter (increments when view changes).
97    view_version: u64,
98    /// Bounds used for last cache build.
99    last_bounds: Option<Rect>,
100    /// Dirty flags tracking what needs updating.
101    dirty_flags: ChartDirtyFlags,
102}
103
104impl Default for ChartCache {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl ChartCache {
111    /// Create a new empty cache.
112    pub fn new() -> Self {
113        Self {
114            series_caches: Vec::new(),
115            spatial_index: None,
116            data_version: 0,
117            view_version: 0,
118            last_bounds: None,
119            dirty_flags: ChartDirtyFlags::all(),
120        }
121    }
122
123    /// Get the dirty flags.
124    pub fn dirty_flags(&self) -> ChartDirtyFlags {
125        self.dirty_flags
126    }
127
128    /// Mark the cache as needing a full rebuild.
129    pub fn invalidate(&mut self) {
130        self.dirty_flags = ChartDirtyFlags::all();
131    }
132
133    /// Mark data as changed.
134    pub fn mark_data_changed(&mut self) {
135        self.dirty_flags.insert(ChartDirtyFlags::DATA_CHANGED);
136        self.data_version = self.data_version.wrapping_add(1);
137    }
138
139    /// Mark data as appended (partial update possible).
140    pub fn mark_data_appended(&mut self, series_idx: usize, new_count: usize) {
141        if series_idx < self.series_caches.len() {
142            // Only mark as appended if current cache has fewer points
143            let cache = &self.series_caches[series_idx];
144            if cache.data_count < new_count {
145                self.dirty_flags.insert(ChartDirtyFlags::DATA_APPENDED);
146            }
147        } else {
148            // Series doesn't exist in cache yet, mark as full change
149            self.dirty_flags.insert(ChartDirtyFlags::DATA_CHANGED);
150        }
151    }
152
153    /// Mark view as changed (pan/zoom).
154    pub fn mark_view_changed(&mut self) {
155        self.dirty_flags.insert(ChartDirtyFlags::VIEW_CHANGED);
156        self.view_version = self.view_version.wrapping_add(1);
157    }
158
159    /// Mark style as changed.
160    pub fn mark_style_changed(&mut self) {
161        self.dirty_flags.insert(ChartDirtyFlags::STYLE_CHANGED);
162    }
163
164    /// Mark axes as changed.
165    pub fn mark_axes_changed(&mut self) {
166        self.dirty_flags.insert(ChartDirtyFlags::AXES_CHANGED);
167    }
168
169    /// Mark bounds as changed.
170    pub fn mark_bounds_changed(&mut self) {
171        self.dirty_flags.insert(ChartDirtyFlags::BOUNDS_CHANGED);
172    }
173
174    /// Clear dirty flags after processing.
175    pub fn clear_dirty(&mut self) {
176        self.dirty_flags = ChartDirtyFlags::empty();
177    }
178
179    /// Check if cache needs to be rebuilt.
180    pub fn needs_rebuild(&self) -> bool {
181        self.dirty_flags.needs_cache_rebuild()
182    }
183
184    /// Rebuild the cache for a chart.
185    pub fn rebuild(&mut self, chart: &Chart, bounds: &Rect) {
186        let plot_area = bounds.inset(chart.padding);
187
188        // Check if we can do a partial update
189        if self.dirty_flags.is_append_only() && self.last_bounds == Some(*bounds) {
190            self.partial_update(chart, &plot_area);
191        } else {
192            self.full_rebuild(chart, &plot_area);
193        }
194
195        self.last_bounds = Some(*bounds);
196        self.clear_dirty();
197    }
198
199    /// Perform a full rebuild of the cache.
200    fn full_rebuild(&mut self, chart: &Chart, plot_area: &Rect) {
201        // Resize series caches to match chart
202        self.series_caches.resize_with(chart.series.len(), || {
203            SeriesPixelCache::new(AxisId::X_PRIMARY, AxisId::Y_PRIMARY)
204        });
205
206        // Rebuild each series cache
207        for (series_idx, series) in chart.series.iter().enumerate() {
208            let x_range = chart.axis_range(series.x_axis);
209            let y_range = chart.axis_range(series.y_axis);
210
211            let cache = &mut self.series_caches[series_idx];
212            cache.x_axis = series.x_axis;
213            cache.y_axis = series.y_axis;
214            cache.x_range = x_range;
215            cache.y_range = y_range;
216            cache.data_count = series.data.len();
217
218            // Compute pixel positions
219            cache.positions.clear();
220            cache.positions.reserve(series.data.len());
221
222            for point in &series.data {
223                let pixel = data_to_pixel(point, plot_area, x_range, y_range);
224                cache.positions.push(pixel);
225            }
226        }
227
228        // Rebuild spatial index
229        self.rebuild_spatial_index(plot_area);
230    }
231
232    /// Perform a partial update (append only).
233    fn partial_update(&mut self, chart: &Chart, plot_area: &Rect) {
234        for (series_idx, series) in chart.series.iter().enumerate() {
235            if series_idx >= self.series_caches.len() {
236                // New series, need full rebuild for this one
237                self.series_caches
238                    .push(SeriesPixelCache::new(series.x_axis, series.y_axis));
239            }
240
241            let cache = &mut self.series_caches[series_idx];
242            let x_range = chart.axis_range(series.x_axis);
243            let y_range = chart.axis_range(series.y_axis);
244
245            // Check if ranges changed (would need full rebuild)
246            if cache.x_range != x_range || cache.y_range != y_range {
247                // Ranges changed, rebuild this series
248                cache.x_range = x_range;
249                cache.y_range = y_range;
250                cache.positions.clear();
251                cache.positions.reserve(series.data.len());
252                for point in &series.data {
253                    let pixel = data_to_pixel(point, plot_area, x_range, y_range);
254                    cache.positions.push(pixel);
255                }
256            } else if series.data.len() > cache.data_count {
257                // Append new points
258                cache
259                    .positions
260                    .reserve(series.data.len() - cache.data_count);
261                for point in &series.data[cache.data_count..] {
262                    let pixel = data_to_pixel(point, plot_area, x_range, y_range);
263                    cache.positions.push(pixel);
264                }
265            }
266
267            cache.data_count = series.data.len();
268        }
269
270        // Rebuild spatial index with new data
271        self.rebuild_spatial_index(plot_area);
272    }
273
274    /// Rebuild the spatial index from cached positions.
275    fn rebuild_spatial_index(&mut self, plot_area: &Rect) {
276        let mut index = SpatialIndex::new(*plot_area, 32, 32);
277
278        for (series_idx, cache) in self.series_caches.iter().enumerate() {
279            for (point_idx, &pos) in cache.positions.iter().enumerate() {
280                index.insert(pos, series_idx, point_idx);
281            }
282        }
283
284        self.spatial_index = Some(index);
285    }
286
287    /// Get cached pixel positions for a series.
288    pub fn series_positions(&self, series_idx: usize) -> Option<&[Vec2]> {
289        self.series_caches
290            .get(series_idx)
291            .map(|c| c.positions.as_slice())
292    }
293
294    /// Get the spatial index for hit testing.
295    pub fn spatial_index(&self) -> Option<&SpatialIndex> {
296        self.spatial_index.as_ref()
297    }
298
299    /// Perform a fast hit test using the spatial index.
300    pub fn hit_test(
301        &self,
302        chart: &Chart,
303        pixel: Vec2,
304        max_distance: f32,
305    ) -> Option<CacheHitResult> {
306        let index = self.spatial_index.as_ref()?;
307
308        let mut best: Option<CacheHitResult> = None;
309
310        for (series_idx, point_idx) in index.query_near(pixel, max_distance) {
311            if let Some(cache) = self.series_caches.get(series_idx)
312                && let Some(&point_pixel) = cache.positions.get(point_idx)
313            {
314                let dist = pixel.distance(point_pixel);
315                if dist <= max_distance && best.as_ref().is_none_or(|b| dist < b.distance) {
316                    let data_point = chart
317                        .series
318                        .get(series_idx)
319                        .and_then(|s| s.data.get(point_idx))
320                        .copied();
321
322                    if let Some(data_point) = data_point {
323                        best = Some(CacheHitResult {
324                            series_index: series_idx,
325                            point_index: point_idx,
326                            distance: dist,
327                            data_point,
328                            pixel_position: point_pixel,
329                        });
330                    }
331                }
332            }
333        }
334
335        best
336    }
337}
338
339/// Result of a hit test using the cache.
340#[derive(Debug, Clone)]
341pub struct CacheHitResult {
342    /// Series index.
343    pub series_index: usize,
344    /// Point index within the series.
345    pub point_index: usize,
346    /// Distance from the test point to the data point (in pixels).
347    pub distance: f32,
348    /// The data point.
349    pub data_point: DataPoint,
350    /// The pixel position of the data point.
351    pub pixel_position: Vec2,
352}
353
354/// Spatial index for O(1) hit testing.
355///
356/// Uses a uniform grid to partition the chart area, allowing fast
357/// lookup of points near a given position.
358#[derive(Debug, Clone)]
359pub struct SpatialIndex {
360    /// Grid cells, each containing a list of (series_idx, point_idx).
361    cells: Vec<Vec<(usize, usize)>>,
362    /// Number of columns in the grid.
363    cols: usize,
364    /// Number of rows in the grid.
365    rows: usize,
366    /// Bounds of the indexed area.
367    bounds: Rect,
368    /// Width of each cell.
369    cell_width: f32,
370    /// Height of each cell.
371    cell_height: f32,
372}
373
374impl SpatialIndex {
375    /// Create a new spatial index for the given bounds.
376    pub fn new(bounds: Rect, cols: usize, rows: usize) -> Self {
377        let cols = cols.max(1);
378        let rows = rows.max(1);
379
380        Self {
381            cells: vec![Vec::new(); cols * rows],
382            cols,
383            rows,
384            bounds,
385            cell_width: bounds.width / cols as f32,
386            cell_height: bounds.height / rows as f32,
387        }
388    }
389
390    /// Insert a point into the index.
391    pub fn insert(&mut self, pos: Vec2, series_idx: usize, point_idx: usize) {
392        if let Some(cell_idx) = self.cell_index(pos) {
393            self.cells[cell_idx].push((series_idx, point_idx));
394        }
395    }
396
397    /// Clear all entries from the index.
398    pub fn clear(&mut self) {
399        for cell in &mut self.cells {
400            cell.clear();
401        }
402    }
403
404    /// Get the cell index for a position.
405    fn cell_index(&self, pos: Vec2) -> Option<usize> {
406        if !self.bounds.contains(pos) {
407            return None;
408        }
409
410        let col = ((pos.x - self.bounds.x) / self.cell_width) as usize;
411        let row = ((pos.y - self.bounds.y) / self.cell_height) as usize;
412
413        let col = col.min(self.cols - 1);
414        let row = row.min(self.rows - 1);
415
416        Some(row * self.cols + col)
417    }
418
419    /// Get the cell coordinates for a position.
420    fn cell_coords(&self, pos: Vec2) -> Option<(usize, usize)> {
421        if !self.bounds.contains(pos) {
422            // Clamp to bounds for edge cases
423            let x = pos.x.clamp(self.bounds.x, self.bounds.right());
424            let y = pos.y.clamp(self.bounds.y, self.bounds.bottom());
425
426            let col = ((x - self.bounds.x) / self.cell_width) as usize;
427            let row = ((y - self.bounds.y) / self.cell_height) as usize;
428
429            return Some((col.min(self.cols - 1), row.min(self.rows - 1)));
430        }
431
432        let col = ((pos.x - self.bounds.x) / self.cell_width) as usize;
433        let row = ((pos.y - self.bounds.y) / self.cell_height) as usize;
434
435        Some((col.min(self.cols - 1), row.min(self.rows - 1)))
436    }
437
438    /// Query points near a position within a radius.
439    ///
440    /// Returns an iterator over (series_idx, point_idx) pairs.
441    pub fn query_near(&self, pos: Vec2, radius: f32) -> impl Iterator<Item = (usize, usize)> + '_ {
442        // Calculate cell range to check
443        let (center_col, center_row) = self.cell_coords(pos).unwrap_or((0, 0));
444
445        let cells_x = (radius / self.cell_width).ceil() as usize + 1;
446        let cells_y = (radius / self.cell_height).ceil() as usize + 1;
447
448        let min_col = center_col.saturating_sub(cells_x);
449        let max_col = (center_col + cells_x).min(self.cols - 1);
450        let min_row = center_row.saturating_sub(cells_y);
451        let max_row = (center_row + cells_y).min(self.rows - 1);
452
453        // Collect all candidates from nearby cells
454        SpatialQueryIter {
455            index: self,
456            min_col,
457            max_col,
458            min_row,
459            max_row,
460            current_col: min_col,
461            current_row: min_row,
462            current_entry: 0,
463        }
464    }
465
466    /// Get the number of points in the index.
467    pub fn len(&self) -> usize {
468        self.cells.iter().map(|c| c.len()).sum()
469    }
470
471    /// Check if the index is empty.
472    pub fn is_empty(&self) -> bool {
473        self.cells.iter().all(|c| c.is_empty())
474    }
475}
476
477/// Iterator for spatial queries.
478struct SpatialQueryIter<'a> {
479    index: &'a SpatialIndex,
480    min_col: usize,
481    max_col: usize,
482    #[allow(dead_code)] // Used to initialize current_row
483    min_row: usize,
484    max_row: usize,
485    current_col: usize,
486    current_row: usize,
487    current_entry: usize,
488}
489
490impl Iterator for SpatialQueryIter<'_> {
491    type Item = (usize, usize);
492
493    fn next(&mut self) -> Option<Self::Item> {
494        loop {
495            if self.current_row > self.max_row {
496                return None;
497            }
498
499            let cell_idx = self.current_row * self.index.cols + self.current_col;
500            let cell = &self.index.cells[cell_idx];
501
502            if self.current_entry < cell.len() {
503                let result = cell[self.current_entry];
504                self.current_entry += 1;
505                return Some(result);
506            }
507
508            // Move to next cell
509            self.current_entry = 0;
510            self.current_col += 1;
511
512            if self.current_col > self.max_col {
513                self.current_col = self.min_col;
514                self.current_row += 1;
515            }
516        }
517    }
518}
519
520/// Convert a data point to pixel coordinates.
521fn data_to_pixel(
522    point: &DataPoint,
523    plot_area: &Rect,
524    x_range: (f64, f64),
525    y_range: (f64, f64),
526) -> Vec2 {
527    let (x_min, x_max) = x_range;
528    let (y_min, y_max) = y_range;
529
530    let px = plot_area.x + ((point.x - x_min) / (x_max - x_min)) as f32 * plot_area.width;
531    // Y is inverted (0 at top in screen coords)
532    let py = plot_area.y + plot_area.height
533        - ((point.y - y_min) / (y_max - y_min)) as f32 * plot_area.height;
534
535    Vec2::new(px, py)
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_dirty_flags() {
544        let mut flags = ChartDirtyFlags::empty();
545        assert!(!flags.needs_cache_rebuild());
546
547        flags.insert(ChartDirtyFlags::STYLE_CHANGED);
548        assert!(!flags.needs_cache_rebuild());
549        assert!(flags.is_style_only());
550
551        flags.insert(ChartDirtyFlags::DATA_CHANGED);
552        assert!(flags.needs_cache_rebuild());
553        assert!(!flags.is_style_only());
554    }
555
556    #[test]
557    fn test_spatial_index() {
558        let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
559        let mut index = SpatialIndex::new(bounds, 10, 10);
560
561        // Insert some points
562        index.insert(Vec2::new(15.0, 15.0), 0, 0);
563        index.insert(Vec2::new(25.0, 25.0), 0, 1);
564        index.insert(Vec2::new(85.0, 85.0), 1, 0);
565
566        assert_eq!(index.len(), 3);
567
568        // Query near first point
569        let results: Vec<_> = index.query_near(Vec2::new(15.0, 15.0), 20.0).collect();
570        assert!(results.contains(&(0, 0)));
571        assert!(results.contains(&(0, 1)));
572        assert!(!results.contains(&(1, 0)));
573    }
574}