oxidize_pdf/dashboard/
layout.rs

1//! Dashboard Layout System - 12-Column Grid with Responsive Design
2//!
3//! This module implements a CSS Grid-like system for positioning dashboard components
4//! in a 12-column responsive grid. It handles component positioning, row management,
5//! spacing, and responsive breakpoints.
6
7use super::{ComponentPosition, ComponentSpan, DashboardComponent, DashboardConfig};
8use crate::error::PdfError;
9use std::collections::HashMap;
10
11/// Main layout manager for dashboard components
12#[derive(Debug, Clone)]
13pub struct DashboardLayout {
14    /// Layout configuration
15    config: DashboardConfig,
16    /// Grid system
17    grid: GridSystem,
18    /// Component positions cache
19    position_cache: HashMap<String, ComponentPosition>,
20}
21
22impl DashboardLayout {
23    /// Create a new dashboard layout
24    pub fn new(config: DashboardConfig) -> Self {
25        let grid = GridSystem::new(12, config.column_gutter, config.row_gutter);
26
27        Self {
28            config,
29            grid,
30            position_cache: HashMap::new(),
31        }
32    }
33
34    /// Calculate the content area based on page bounds and configuration
35    pub fn calculate_content_area(
36        &self,
37        page_bounds: (f64, f64, f64, f64),
38    ) -> (f64, f64, f64, f64) {
39        let (page_x, page_y, page_width, page_height) = page_bounds;
40        let (margin_top, margin_right, margin_bottom, margin_left) = self.config.margins;
41
42        // Calculate basic content area
43        let mut content_x = page_x + margin_left;
44        let content_y = page_y + margin_top;
45        let mut content_width = page_width - margin_left - margin_right;
46        let content_height = page_height
47            - margin_top
48            - margin_bottom
49            - self.config.header_height
50            - self.config.footer_height;
51
52        // Apply maximum content width if specified
53        if self.config.max_content_width > 0.0 && content_width > self.config.max_content_width {
54            content_width = self.config.max_content_width;
55
56            // Center content if enabled
57            if self.config.center_content {
58                content_x = page_x + (page_width - content_width) / 2.0;
59            }
60        }
61
62        (content_x, content_y, content_width, content_height)
63    }
64
65    /// Calculate positions for all components in the dashboard
66    pub fn calculate_positions(
67        &self,
68        components: &[Box<dyn DashboardComponent>],
69        content_area: (f64, f64, f64, f64),
70    ) -> Result<Vec<ComponentPosition>, PdfError> {
71        let (content_x, content_y, content_width, content_height) = content_area;
72
73        // Adjust content area to account for header
74        let layout_y = content_y + content_height - self.config.header_height;
75        let layout_height = content_height - self.config.header_height;
76
77        // Use grid system to calculate positions
78        self.grid.layout_components(
79            components,
80            content_x,
81            layout_y,
82            content_width,
83            layout_height,
84            self.config.default_component_height,
85        )
86    }
87
88    /// Get layout statistics
89    pub fn get_stats(&self, components: &[Box<dyn DashboardComponent>]) -> LayoutStats {
90        let total_components = components.len();
91        let rows_used = self.estimate_rows_needed(components);
92        let column_utilization = self.calculate_column_utilization(components);
93
94        LayoutStats {
95            total_components,
96            rows_used,
97            column_utilization,
98            has_overflow: column_utilization > 1.0,
99        }
100    }
101
102    /// Estimate number of rows needed for components
103    fn estimate_rows_needed(&self, components: &[Box<dyn DashboardComponent>]) -> usize {
104        let mut current_row_span = 0;
105        let mut rows = 0;
106
107        for component in components {
108            let span = component.get_span().columns;
109
110            if current_row_span + span > 12 {
111                rows += 1;
112                current_row_span = span;
113            } else {
114                current_row_span += span;
115                if current_row_span == 12 {
116                    rows += 1;
117                    current_row_span = 0;
118                }
119            }
120        }
121
122        if current_row_span > 0 {
123            rows += 1;
124        }
125
126        rows.max(1)
127    }
128
129    /// Calculate average column utilization
130    fn calculate_column_utilization(&self, components: &[Box<dyn DashboardComponent>]) -> f64 {
131        if components.is_empty() {
132            return 0.0;
133        }
134
135        let total_span: u32 = components.iter().map(|c| c.get_span().columns as u32).sum();
136
137        let estimated_rows = self.estimate_rows_needed(components) as u32;
138        let available_columns = estimated_rows * 12;
139
140        if available_columns > 0 {
141            total_span as f64 / available_columns as f64
142        } else {
143            1.0
144        }
145    }
146}
147
148/// Grid system for component layout
149#[derive(Debug, Clone)]
150pub struct GridSystem {
151    /// Number of columns in the grid
152    columns: u8,
153    /// Gutter between columns
154    column_gutter: f64,
155    /// Gutter between rows  
156    row_gutter: f64,
157}
158
159impl GridSystem {
160    /// Create a new grid system
161    pub fn new(columns: u8, column_gutter: f64, row_gutter: f64) -> Self {
162        Self {
163            columns,
164            column_gutter,
165            row_gutter,
166        }
167    }
168
169    /// Layout components in the grid
170    pub fn layout_components(
171        &self,
172        components: &[Box<dyn DashboardComponent>],
173        start_x: f64,
174        start_y: f64,
175        total_width: f64,
176        total_height: f64,
177        default_height: f64,
178    ) -> Result<Vec<ComponentPosition>, PdfError> {
179        let mut positions = Vec::new();
180
181        // Start from the top and work downward (PDF coordinates)
182        let mut current_y = start_y;
183        let mut row_start = 0;
184
185        // Calculate column width accounting for gutters
186        let total_gutter_width = (self.columns as f64 - 1.0) * self.column_gutter;
187        let available_width = total_width - total_gutter_width;
188        let column_width = available_width / self.columns as f64;
189
190        // Reduce default height to fit more components
191        let adjusted_height = (default_height * 0.6).max(120.0); // Minimum 120 points
192
193        while row_start < components.len() {
194            // Find components for current row
195            let row_end = self.find_row_end(components, row_start);
196            let row_components = &components[row_start..row_end];
197
198            // Calculate row height - use consistent height for KPI cards
199            let row_height = adjusted_height;
200
201            // Check if we have enough space for this row
202            if current_y - row_height < start_y - total_height {
203                tracing::warn!(
204                    "Dashboard components exceed available height, stopping at row {}",
205                    positions.len() / row_components.len()
206                );
207                break;
208            }
209
210            // Position components in this row
211            let mut current_x = start_x;
212
213            for component in row_components {
214                let span = component.get_span();
215                let component_width = column_width * span.columns as f64
216                    + self.column_gutter * (span.columns as f64 - 1.0);
217
218                // Position component at current_y - row_height (bottom of component)
219                positions.push(ComponentPosition::new(
220                    current_x,
221                    current_y - row_height,
222                    component_width,
223                    row_height,
224                ));
225
226                current_x += component_width + self.column_gutter;
227            }
228
229            // Move to next row with proper spacing
230            current_y -= row_height + self.row_gutter;
231            row_start = row_end;
232        }
233
234        Ok(positions)
235    }
236
237    /// Find the end index for the current row
238    fn find_row_end(&self, components: &[Box<dyn DashboardComponent>], start: usize) -> usize {
239        let mut current_span = 0;
240        let mut end = start;
241
242        for (i, component) in components[start..].iter().enumerate() {
243            let span = component.get_span().columns;
244
245            if current_span + span > self.columns {
246                break;
247            }
248
249            current_span += span;
250            end = start + i + 1;
251
252            if current_span == self.columns {
253                break;
254            }
255        }
256
257        end.max(start + 1) // Ensure at least one component per row
258    }
259
260    /// Calculate the height needed for a row of components
261    fn calculate_row_height(
262        &self,
263        components: &[Box<dyn DashboardComponent>],
264        column_width: f64,
265        default_height: f64,
266    ) -> f64 {
267        components
268            .iter()
269            .map(|component| {
270                let span = component.get_span();
271                let available_width = column_width * span.columns as f64;
272                component.preferred_height(available_width)
273            })
274            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
275            .unwrap_or(default_height)
276    }
277}
278
279/// Layout manager for advanced positioning and responsive behavior
280#[derive(Debug, Clone)]
281pub struct LayoutManager {
282    /// Current layout state
283    state: LayoutState,
284    /// Responsive breakpoints
285    breakpoints: HashMap<String, f64>,
286}
287
288impl LayoutManager {
289    /// Create a new layout manager
290    pub fn new() -> Self {
291        Self {
292            state: LayoutState::default(),
293            breakpoints: HashMap::new(),
294        }
295    }
296
297    /// Add a responsive breakpoint
298    pub fn add_breakpoint<T: Into<String>>(&mut self, name: T, width: f64) {
299        self.breakpoints.insert(name.into(), width);
300    }
301
302    /// Get current breakpoint based on available width
303    pub fn get_current_breakpoint(&self, width: f64) -> String {
304        let mut best_match = "default".to_string();
305        let mut best_width = 0.0;
306
307        for (name, breakpoint_width) in &self.breakpoints {
308            if width >= *breakpoint_width && *breakpoint_width > best_width {
309                best_match = name.clone();
310                best_width = *breakpoint_width;
311            }
312        }
313
314        best_match
315    }
316
317    /// Optimize layout for the given constraints
318    pub fn optimize_layout(
319        &self,
320        components: &mut [Box<dyn DashboardComponent>],
321        available_width: f64,
322    ) -> Result<(), PdfError> {
323        let breakpoint = self.get_current_breakpoint(available_width);
324
325        // Apply responsive adjustments based on breakpoint
326        match breakpoint.as_str() {
327            "small" => self.apply_mobile_layout(components)?,
328            "medium" => self.apply_tablet_layout(components)?,
329            _ => {} // Use default layout
330        }
331
332        Ok(())
333    }
334
335    /// Apply mobile-friendly layout adjustments
336    fn apply_mobile_layout(
337        &self,
338        components: &mut [Box<dyn DashboardComponent>],
339    ) -> Result<(), PdfError> {
340        for component in components.iter_mut() {
341            // Force components to full width on mobile
342            component.set_span(ComponentSpan::new(12));
343        }
344        Ok(())
345    }
346
347    /// Apply tablet-friendly layout adjustments
348    fn apply_tablet_layout(
349        &self,
350        components: &mut [Box<dyn DashboardComponent>],
351    ) -> Result<(), PdfError> {
352        for component in components.iter_mut() {
353            let current_span = component.get_span().columns;
354
355            // Adjust spans for tablet layout
356            let new_span = match current_span {
357                1..=3 => 6,   // Quarter -> Half width
358                4..=6 => 6,   // Keep half width
359                7..=12 => 12, // Keep full width
360                _ => current_span,
361            };
362
363            component.set_span(ComponentSpan::new(new_span));
364        }
365        Ok(())
366    }
367}
368
369impl Default for LayoutManager {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375/// Current state of the layout system
376#[derive(Debug, Clone)]
377pub struct LayoutState {
378    /// Current row being processed
379    pub current_row: usize,
380    /// Current column position in row
381    pub current_column: u8,
382    /// Total rows used
383    pub total_rows: usize,
384}
385
386impl Default for LayoutState {
387    fn default() -> Self {
388        Self {
389            current_row: 0,
390            current_column: 0,
391            total_rows: 0,
392        }
393    }
394}
395
396/// Grid position for component placement
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398pub struct GridPosition {
399    /// Row number (0-based)
400    pub row: usize,
401    /// Column start (0-based)
402    pub column_start: u8,
403    /// Column span (1-12)
404    pub column_span: u8,
405    /// Row span (default 1)
406    pub row_span: u8,
407}
408
409impl GridPosition {
410    /// Create a new grid position
411    pub fn new(row: usize, column_start: u8, column_span: u8) -> Self {
412        Self {
413            row,
414            column_start,
415            column_span,
416            row_span: 1,
417        }
418    }
419
420    /// Create a position with row span
421    pub fn with_row_span(mut self, row_span: u8) -> Self {
422        self.row_span = row_span;
423        self
424    }
425
426    /// Get the ending column (exclusive)
427    pub fn column_end(&self) -> u8 {
428        self.column_start + self.column_span
429    }
430
431    /// Check if this position overlaps with another
432    pub fn overlaps(&self, other: &GridPosition) -> bool {
433        self.row < other.row + other.row_span as usize
434            && other.row < self.row + self.row_span as usize
435            && self.column_start < other.column_end()
436            && other.column_start < self.column_end()
437    }
438}
439
440/// Layout statistics for monitoring and debugging
441#[derive(Debug, Clone)]
442pub struct LayoutStats {
443    /// Total number of components
444    pub total_components: usize,
445    /// Number of rows used
446    pub rows_used: usize,
447    /// Column utilization (0.0-1.0, >1.0 indicates overflow)
448    pub column_utilization: f64,
449    /// Whether there's content overflow
450    pub has_overflow: bool,
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_grid_system() {
459        let grid = GridSystem::new(12, 15.0, 20.0);
460        assert_eq!(grid.columns, 12);
461        assert_eq!(grid.column_gutter, 15.0);
462        assert_eq!(grid.row_gutter, 20.0);
463    }
464
465    #[test]
466    fn test_grid_position() {
467        let pos1 = GridPosition::new(0, 0, 6);
468        let pos2 = GridPosition::new(0, 6, 6);
469        let pos3 = GridPosition::new(0, 3, 6);
470
471        assert!(!pos1.overlaps(&pos2));
472        assert!(pos1.overlaps(&pos3));
473        assert_eq!(pos1.column_end(), 6);
474    }
475
476    #[test]
477    fn test_layout_manager_breakpoints() {
478        let mut manager = LayoutManager::new();
479        manager.add_breakpoint("small", 400.0);
480        manager.add_breakpoint("medium", 768.0);
481        manager.add_breakpoint("large", 1024.0);
482
483        assert_eq!(manager.get_current_breakpoint(300.0), "default");
484        assert_eq!(manager.get_current_breakpoint(500.0), "small");
485        assert_eq!(manager.get_current_breakpoint(800.0), "medium");
486        assert_eq!(manager.get_current_breakpoint(1200.0), "large");
487    }
488
489    #[test]
490    fn test_dashboard_layout_content_area() {
491        let config = DashboardConfig::default();
492        let layout = DashboardLayout::new(config);
493
494        let page_bounds = (0.0, 0.0, 800.0, 600.0);
495        let content_area = layout.calculate_content_area(page_bounds);
496
497        // Should account for margins, header, and footer
498        assert_eq!(content_area.0, 30.0); // Left margin
499        assert!(content_area.2 < 800.0); // Width reduced by margins
500        assert!(content_area.3 < 600.0); // Height reduced by margins + header + footer
501    }
502}