Skip to main content

boxmux_lib/components/dimensions/
progress_dimensions.rs

1use super::Orientation;
2use crate::Bounds;
3
4/// ProgressDimensions - Centralizes ALL progress bar and chart mathematical operations
5///
6/// Eliminates ad hoc progress math like:
7/// - filled_width = ((bar_width as f64) * progress).round() as usize
8/// - Chart scaling and data mapping scattered throughout chart components
9/// - Progress indicator positioning and sizing calculations
10///
11/// Replaces scattered progress logic from progress_bar.rs, chart_component.rs
12#[derive(Debug, Clone)]
13pub struct ProgressDimensions {
14    /// Available bounds for progress display
15    bounds: Bounds,
16    /// Progress orientation
17    orientation: Orientation,
18    /// Current progress value (0.0 to 1.0)
19    progress: f64,
20    /// Data range for value mapping (min, max)
21    data_range: (f64, f64),
22}
23
24impl ProgressDimensions {
25    /// Create new progress dimensions
26    pub fn new(bounds: Bounds, orientation: Orientation) -> Self {
27        Self {
28            bounds,
29            orientation,
30            progress: 0.0,
31            data_range: (0.0, 1.0),
32        }
33    }
34
35    /// Set current progress (0.0 to 1.0)
36    pub fn set_progress(&mut self, progress: f64) {
37        self.progress = progress.clamp(0.0, 1.0);
38    }
39
40    /// Set data range for value mapping
41    pub fn set_data_range(&mut self, min: f64, max: f64) {
42        self.data_range = (min, max);
43    }
44
45    /// Calculate filled area size based on progress
46    /// Centralizes: filled_width = ((bar_width as f64) * progress).round() as usize
47    pub fn calculate_fill_size(&self) -> usize {
48        let total_size = match self.orientation {
49            Orientation::Horizontal => self.bounds.width(),
50            Orientation::Vertical => self.bounds.height(),
51        };
52
53        if total_size == 0 {
54            return 0;
55        }
56
57        ((total_size as f64) * self.progress).round() as usize
58    }
59
60    /// Get filled area bounds for drawing
61    pub fn get_fill_bounds(&self) -> Bounds {
62        let fill_size = self.calculate_fill_size();
63
64        match self.orientation {
65            Orientation::Horizontal => {
66                // Fill from left
67                Bounds::new(
68                    self.bounds.x1,
69                    self.bounds.y1,
70                    self.bounds.x1 + fill_size.saturating_sub(1),
71                    self.bounds.y2,
72                )
73            }
74            Orientation::Vertical => {
75                // Fill from bottom up
76                Bounds::new(
77                    self.bounds.x1,
78                    self.bounds.y2.saturating_sub(fill_size.saturating_sub(1)),
79                    self.bounds.x2,
80                    self.bounds.y2,
81                )
82            }
83        }
84    }
85
86    /// Get unfilled area bounds for drawing
87    pub fn get_empty_bounds(&self) -> Option<Bounds> {
88        let fill_size = self.calculate_fill_size();
89        let total_size = match self.orientation {
90            Orientation::Horizontal => self.bounds.width(),
91            Orientation::Vertical => self.bounds.height(),
92        };
93
94        if fill_size >= total_size {
95            return None; // Completely filled
96        }
97
98        match self.orientation {
99            Orientation::Horizontal => {
100                // Empty area to the right of fill
101                Some(Bounds::new(
102                    self.bounds.x1 + fill_size,
103                    self.bounds.y1,
104                    self.bounds.x2,
105                    self.bounds.y2,
106                ))
107            }
108            Orientation::Vertical => {
109                // Empty area above fill
110                Some(Bounds::new(
111                    self.bounds.x1,
112                    self.bounds.y1,
113                    self.bounds.x2,
114                    self.bounds.y2.saturating_sub(fill_size),
115                ))
116            }
117        }
118    }
119
120    /// Map a data value to pixel position within bounds
121    /// Centralizes chart data-to-pixel mapping
122    pub fn map_value_to_pixel(&self, value: f64) -> usize {
123        let (min_val, max_val) = self.data_range;
124        let range = max_val - min_val;
125
126        if range <= 0.0 {
127            return match self.orientation {
128                Orientation::Horizontal => self.bounds.x1,
129                Orientation::Vertical => self.bounds.y1,
130            };
131        }
132
133        let normalized = ((value - min_val) / range).clamp(0.0, 1.0);
134        let available_size = match self.orientation {
135            Orientation::Horizontal => self.bounds.width(),
136            Orientation::Vertical => self.bounds.height(),
137        };
138
139        let pixel_offset = (normalized * (available_size - 1) as f64).floor() as usize;
140
141        match self.orientation {
142            Orientation::Horizontal => self.bounds.x1 + pixel_offset,
143            Orientation::Vertical => {
144                // Vertical charts often display with higher values at the top
145                self.bounds.y2 - pixel_offset
146            }
147        }
148    }
149
150    /// Map pixel position back to data value
151    /// Useful for interactive charts
152    pub fn map_pixel_to_value(&self, pixel: usize) -> f64 {
153        let (min_val, max_val) = self.data_range;
154        let range = max_val - min_val;
155
156        let relative_pixel = match self.orientation {
157            Orientation::Horizontal => pixel.saturating_sub(self.bounds.x1),
158            Orientation::Vertical => {
159                // Reverse for vertical (higher Y = lower value)
160                self.bounds.y2.saturating_sub(pixel)
161            }
162        };
163
164        let available_size = match self.orientation {
165            Orientation::Horizontal => self.bounds.width(),
166            Orientation::Vertical => self.bounds.height(),
167        };
168
169        if available_size <= 1 {
170            return min_val;
171        }
172
173        let normalized = (relative_pixel as f64) / ((available_size - 1) as f64);
174        min_val + (normalized * range)
175    }
176
177    /// Calculate progress bar segment positions for multi-segment bars
178    pub fn calculate_segments(&self, segment_count: usize) -> Vec<ProgressSegment> {
179        if segment_count == 0 {
180            return vec![];
181        }
182
183        let total_size = match self.orientation {
184            Orientation::Horizontal => self.bounds.width(),
185            Orientation::Vertical => self.bounds.height(),
186        };
187
188        let segment_size = total_size / segment_count;
189        let remainder = total_size % segment_count;
190        let mut segments = Vec::new();
191
192        let mut current_pos = match self.orientation {
193            Orientation::Horizontal => self.bounds.x1,
194            Orientation::Vertical => self.bounds.y1,
195        };
196
197        for i in 0..segment_count {
198            // Distribute remainder across first few segments
199            let this_segment_size = if i < remainder {
200                segment_size + 1
201            } else {
202                segment_size
203            };
204
205            let filled = (self.progress * segment_count as f64) >= (i + 1) as f64;
206            let partial_fill = if filled {
207                1.0 // Fully filled
208            } else {
209                // Calculate partial fill for current segment
210                let segment_progress = (self.progress * segment_count as f64) - i as f64;
211                if segment_progress > 0.0 {
212                    segment_progress.min(1.0)
213                } else {
214                    0.0
215                }
216            };
217
218            let segment_bounds = match self.orientation {
219                Orientation::Horizontal => Bounds::new(
220                    current_pos,
221                    self.bounds.y1,
222                    current_pos + this_segment_size - 1,
223                    self.bounds.y2,
224                ),
225                Orientation::Vertical => Bounds::new(
226                    self.bounds.x1,
227                    current_pos,
228                    self.bounds.x2,
229                    current_pos + this_segment_size - 1,
230                ),
231            };
232
233            segments.push(ProgressSegment {
234                bounds: segment_bounds,
235                fill_ratio: partial_fill,
236                is_complete: filled,
237                segment_index: i,
238            });
239
240            current_pos += this_segment_size;
241        }
242
243        segments
244    }
245
246    /// Calculate chart axis tick positions
247    /// Centralizes chart axis calculation
248    pub fn calculate_axis_ticks(&self, tick_count: usize) -> Vec<AxisTick> {
249        if tick_count <= 1 {
250            return vec![];
251        }
252
253        let (min_val, max_val) = self.data_range;
254        let value_step = (max_val - min_val) / (tick_count - 1) as f64;
255        let mut ticks = Vec::new();
256
257        for i in 0..tick_count {
258            let value = min_val + (i as f64 * value_step);
259            let pixel = self.map_value_to_pixel(value);
260
261            ticks.push(AxisTick {
262                value,
263                pixel_position: pixel,
264                label: format!("{:.1}", value),
265                is_major: i % 5 == 0, // Every 5th tick is major
266            });
267        }
268
269        ticks
270    }
271
272    /// Calculate data point positions for line charts
273    pub fn calculate_data_points(&self, data: &[f64]) -> Vec<DataPoint> {
274        if data.is_empty() {
275            return vec![];
276        }
277
278        let mut points = Vec::new();
279        let x_step = if data.len() <= 1 {
280            0.0
281        } else {
282            (self.bounds.width() - 1) as f64 / (data.len() - 1) as f64
283        };
284
285        for (i, &value) in data.iter().enumerate() {
286            let x = self.bounds.x1 + (i as f64 * x_step).round() as usize;
287            let y = self.map_value_to_pixel(value);
288
289            points.push(DataPoint {
290                value,
291                x,
292                y,
293                index: i,
294            });
295        }
296
297        points
298    }
299
300    /// Calculate bar chart bar positions and heights
301    pub fn calculate_bars(&self, data: &[f64]) -> Vec<ChartBar> {
302        if data.is_empty() {
303            return vec![];
304        }
305
306        let bar_count = data.len();
307        let total_width = self.bounds.width();
308        let bar_width = total_width / bar_count;
309        let remainder = total_width % bar_count;
310
311        let mut bars = Vec::new();
312        let mut current_x = self.bounds.x1;
313
314        for (i, &value) in data.iter().enumerate() {
315            // Distribute remainder pixels across first few bars
316            let this_bar_width = if i < remainder {
317                bar_width + 1
318            } else {
319                bar_width
320            };
321
322            let bar_height_pixels = self.calculate_bar_height(value);
323            let bar_top_y = self
324                .bounds
325                .y2
326                .saturating_sub(bar_height_pixels.saturating_sub(1));
327
328            let bar_bounds = Bounds::new(
329                current_x,
330                bar_top_y,
331                current_x + this_bar_width - 1,
332                self.bounds.y2,
333            );
334
335            bars.push(ChartBar {
336                value,
337                bounds: bar_bounds,
338                height_pixels: bar_height_pixels,
339                index: i,
340            });
341
342            current_x += this_bar_width;
343        }
344
345        bars
346    }
347
348    /// Calculate bar height based on value and available space
349    fn calculate_bar_height(&self, value: f64) -> usize {
350        let (min_val, max_val) = self.data_range;
351        let range = max_val - min_val;
352        let available_height = self.bounds.height();
353
354        if range <= 0.0 || available_height == 0 {
355            return if value > min_val { available_height } else { 0 };
356        }
357
358        let normalized = ((value - min_val) / range).clamp(0.0, 1.0);
359        (normalized * available_height as f64).round() as usize
360    }
361
362    /// Calculate sparkline positions (compact inline charts)
363    pub fn calculate_sparkline(&self, data: &[f64]) -> Vec<SparklinePoint> {
364        if data.is_empty() {
365            return vec![];
366        }
367
368        // For sparklines, use available width directly - one point per pixel
369        let width = self.bounds.width();
370        let mut points = Vec::new();
371
372        if data.len() <= width {
373            // If we have fewer or equal data points than pixels, distribute evenly
374            let x_step = if data.len() == 1 {
375                0.0
376            } else {
377                (width - 1) as f64 / (data.len() - 1) as f64
378            };
379
380            for (i, &value) in data.iter().enumerate() {
381                let x = (i as f64 * x_step).round() as usize;
382                let normalized_y = self.map_value_to_pixel(value);
383
384                points.push(SparklinePoint {
385                    x: self.bounds.x1 + x,
386                    y: normalized_y,
387                    value,
388                    data_index: i,
389                });
390            }
391
392            // Fill remaining pixels with interpolated values if needed
393            if data.len() > 1 {
394                for x_pos in 0..width {
395                    let needs_point = !points.iter().any(|p| p.x == self.bounds.x1 + x_pos);
396                    if needs_point {
397                        // Interpolate value for this position
398                        let data_pos =
399                            (x_pos as f64 / (width - 1) as f64) * (data.len() - 1) as f64;
400                        let data_index = data_pos.floor() as usize;
401                        let next_index = (data_index + 1).min(data.len() - 1);
402
403                        let frac = data_pos - data_index as f64;
404                        let value = data[data_index] + frac * (data[next_index] - data[data_index]);
405                        let normalized_y = self.map_value_to_pixel(value);
406
407                        points.push(SparklinePoint {
408                            x: self.bounds.x1 + x_pos,
409                            y: normalized_y,
410                            value,
411                            data_index,
412                        });
413                    }
414                }
415            }
416        } else {
417            // More data points than pixels - sample data
418            for x_pos in 0..width {
419                let data_pos = (x_pos as f64 / (width - 1) as f64) * (data.len() - 1) as f64;
420                let data_index = data_pos.round() as usize;
421                let value = data[data_index];
422                let normalized_y = self.map_value_to_pixel(value);
423
424                points.push(SparklinePoint {
425                    x: self.bounds.x1 + x_pos,
426                    y: normalized_y,
427                    value,
428                    data_index,
429                });
430            }
431        }
432
433        // Sort by x position for consistency
434        points.sort_by_key(|p| p.x);
435        points
436    }
437
438    /// Validate progress dimensions are reasonable
439    pub fn validate(&self) -> Result<(), ProgressDimensionError> {
440        // Check for degenerate bounds - when x1 == x2 and y1 == y2, we have zero useful area
441        if self.bounds.x1 == self.bounds.x2 && self.bounds.y1 == self.bounds.y2 {
442            return Err(ProgressDimensionError::ZeroDimensions);
443        }
444
445        let (min_val, max_val) = self.data_range;
446        if min_val >= max_val {
447            return Err(ProgressDimensionError::InvalidDataRange {
448                min: min_val,
449                max: max_val,
450            });
451        }
452
453        Ok(())
454    }
455}
456
457#[derive(Debug, Clone, PartialEq)]
458pub struct ProgressSegment {
459    pub bounds: Bounds,
460    pub fill_ratio: f64, // 0.0 to 1.0
461    pub is_complete: bool,
462    pub segment_index: usize,
463}
464
465#[derive(Debug, Clone, PartialEq)]
466pub struct AxisTick {
467    pub value: f64,
468    pub pixel_position: usize,
469    pub label: String,
470    pub is_major: bool,
471}
472
473#[derive(Debug, Clone, PartialEq)]
474pub struct DataPoint {
475    pub value: f64,
476    pub x: usize,
477    pub y: usize,
478    pub index: usize,
479}
480
481#[derive(Debug, Clone, PartialEq)]
482pub struct ChartBar {
483    pub value: f64,
484    pub bounds: Bounds,
485    pub height_pixels: usize,
486    pub index: usize,
487}
488
489#[derive(Debug, Clone, PartialEq)]
490pub struct SparklinePoint {
491    pub x: usize,
492    pub y: usize,
493    pub value: f64,
494    pub data_index: usize,
495}
496
497#[derive(Debug, Clone, PartialEq)]
498pub enum ProgressDimensionError {
499    ZeroDimensions,
500    InvalidDataRange { min: f64, max: f64 },
501    InvalidProgress { value: f64 },
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn test_fill_size_calculation() {
510        let bounds = Bounds::new(0, 0, 19, 5); // 20 wide
511        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
512
513        progress_dims.set_progress(0.5); // 50%
514        let fill_size = progress_dims.calculate_fill_size();
515        assert_eq!(fill_size, 10); // 50% of 20
516
517        progress_dims.set_progress(0.75); // 75%
518        let fill_size = progress_dims.calculate_fill_size();
519        assert_eq!(fill_size, 15); // 75% of 20
520    }
521
522    #[test]
523    fn test_fill_bounds() {
524        let bounds = Bounds::new(5, 5, 14, 7); // 10 wide, starts at (5,5)
525        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
526
527        progress_dims.set_progress(0.6); // 60%
528        let fill_bounds = progress_dims.get_fill_bounds();
529
530        assert_eq!(fill_bounds.x1, 5); // Same start
531        assert_eq!(fill_bounds.x2, 10); // 5 + 6 - 1 (60% of 10 = 6)
532        assert_eq!(fill_bounds.y1, 5);
533        assert_eq!(fill_bounds.y2, 7);
534    }
535
536    #[test]
537    fn test_empty_bounds() {
538        let bounds = Bounds::new(0, 0, 9, 3); // 10 wide
539        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
540
541        progress_dims.set_progress(0.3); // 30%
542        let empty_bounds = progress_dims.get_empty_bounds().unwrap();
543
544        assert_eq!(empty_bounds.x1, 3); // Start after 30% fill
545        assert_eq!(empty_bounds.x2, 9); // End of original bounds
546
547        // Test fully filled - should have no empty area
548        progress_dims.set_progress(1.0);
549        assert!(progress_dims.get_empty_bounds().is_none());
550    }
551
552    #[test]
553    fn test_value_to_pixel_mapping() {
554        let bounds = Bounds::new(0, 0, 99, 19); // 100 wide, 20 tall
555        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
556        progress_dims.set_data_range(0.0, 100.0); // 0 to 100 data range
557
558        // Value 50 should map to middle (pixel 50)
559        let pixel = progress_dims.map_value_to_pixel(50.0);
560        assert_eq!(pixel, 49); // 0 + (50/100) * 99 ≈ 49
561
562        // Value 0 should map to start
563        let pixel = progress_dims.map_value_to_pixel(0.0);
564        assert_eq!(pixel, 0);
565
566        // Value 100 should map to end
567        let pixel = progress_dims.map_value_to_pixel(100.0);
568        assert_eq!(pixel, 99);
569    }
570
571    #[test]
572    fn test_pixel_to_value_mapping() {
573        let bounds = Bounds::new(0, 0, 99, 19); // 100 wide
574        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
575        progress_dims.set_data_range(0.0, 100.0);
576
577        // Pixel 49 should map to value 50 (approximately)
578        let value = progress_dims.map_pixel_to_value(49);
579        assert!((value - 49.5).abs() < 1.0); // Should be close to 49.5
580
581        // Pixel 0 should map to value 0
582        let value = progress_dims.map_pixel_to_value(0);
583        assert!((value - 0.0).abs() < 0.1);
584
585        // Pixel 99 should map to value 100
586        let value = progress_dims.map_pixel_to_value(99);
587        assert!((value - 100.0).abs() < 0.1);
588    }
589
590    #[test]
591    fn test_segments_calculation() {
592        let bounds = Bounds::new(0, 0, 9, 2); // 10 wide
593        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
594        progress_dims.set_progress(0.35); // 35% progress
595
596        let segments = progress_dims.calculate_segments(5); // 5 segments
597        assert_eq!(segments.len(), 5);
598
599        // Each segment should be 2 wide (10/5)
600        for segment in &segments {
601            assert_eq!(segment.bounds.width(), 2);
602        }
603
604        // First segment should be complete (35% > 20%)
605        assert!(segments[0].is_complete);
606
607        // Second segment should be partially filled
608        assert!(!segments[1].is_complete);
609        assert!(segments[1].fill_ratio > 0.0 && segments[1].fill_ratio < 1.0);
610
611        // Later segments should be empty
612        assert_eq!(segments[3].fill_ratio, 0.0);
613        assert_eq!(segments[4].fill_ratio, 0.0);
614    }
615
616    #[test]
617    fn test_axis_ticks() {
618        let bounds = Bounds::new(0, 0, 99, 19);
619        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
620        progress_dims.set_data_range(0.0, 100.0);
621
622        let ticks = progress_dims.calculate_axis_ticks(6); // 6 ticks
623        assert_eq!(ticks.len(), 6);
624
625        // First tick should be at min value
626        assert_eq!(ticks[0].value, 0.0);
627        assert_eq!(ticks[0].pixel_position, 0);
628
629        // Last tick should be at max value
630        assert_eq!(ticks[5].value, 100.0);
631        assert_eq!(ticks[5].pixel_position, 99);
632
633        // Ticks should be evenly spaced
634        let expected_step = 100.0 / 5.0; // (max - min) / (count - 1)
635        for i in 0..ticks.len() {
636            let expected_value = i as f64 * expected_step;
637            assert!((ticks[i].value - expected_value).abs() < 0.1);
638        }
639    }
640
641    #[test]
642    fn test_bar_chart_calculation() {
643        let bounds = Bounds::new(0, 5, 9, 15); // 10 wide, 11 tall
644        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Vertical);
645        progress_dims.set_data_range(0.0, 10.0);
646
647        let data = vec![5.0, 10.0, 2.5]; // 3 bars
648        let bars = progress_dims.calculate_bars(&data);
649
650        assert_eq!(bars.len(), 3);
651
652        // Each bar should be roughly 3-4 wide (10/3 with remainder distribution)
653        assert!(bars[0].bounds.width() >= 3 && bars[0].bounds.width() <= 4);
654
655        // Tallest bar (value 10.0) should have maximum height
656        let max_bar = bars
657            .iter()
658            .max_by(|a, b| a.height_pixels.cmp(&b.height_pixels))
659            .unwrap();
660        assert_eq!(max_bar.value, 10.0);
661        assert_eq!(max_bar.height_pixels, 11); // Full height
662
663        // Bar with value 5.0 should be half height
664        let half_bar = bars.iter().find(|b| b.value == 5.0).unwrap();
665        assert_eq!(half_bar.height_pixels, 6); // Approximately half of 11
666    }
667
668    #[test]
669    fn test_sparkline_calculation() {
670        let bounds = Bounds::new(0, 0, 9, 3); // 10 wide
671        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
672        progress_dims.set_data_range(0.0, 10.0);
673
674        let data = vec![1.0, 5.0, 8.0, 3.0, 9.0]; // 5 data points, 10 pixel width
675        let points = progress_dims.calculate_sparkline(&data);
676
677        assert_eq!(points.len(), 10); // One point per pixel
678
679        // Points should span the full width
680        assert_eq!(points[0].x, 0);
681        assert_eq!(points[9].x, 9);
682
683        // Values should be properly mapped
684        for point in &points {
685            assert!(point.value >= 0.0 && point.value <= 10.0);
686        }
687    }
688
689    #[test]
690    fn test_validation() {
691        let bounds = Bounds::new(0, 0, 10, 5);
692        let mut progress_dims = ProgressDimensions::new(bounds, Orientation::Horizontal);
693
694        // Valid configuration should pass
695        progress_dims.set_data_range(0.0, 100.0);
696        assert!(progress_dims.validate().is_ok());
697
698        // Invalid data range should fail
699        progress_dims.set_data_range(100.0, 50.0); // min > max
700        assert!(progress_dims.validate().is_err());
701
702        // Zero dimensions should fail
703        let zero_bounds = Bounds::new(0, 0, 0, 0); // Zero width/height
704        let zero_progress_dims = ProgressDimensions::new(zero_bounds, Orientation::Horizontal);
705        assert!(zero_progress_dims.validate().is_err());
706    }
707}