Skip to main content

shape_viz_core/layers/
grid.rs

1//! Grid layer for chart background grid lines
2
3use crate::data::ChartData;
4use crate::error::Result;
5use crate::layers::{Layer, LayerStage};
6use crate::renderer::RenderContext;
7use crate::style::ChartStyle;
8use crate::theme::ChartTheme;
9use crate::viewport::Viewport;
10use glam::Vec2;
11use serde_json::Value;
12
13/// Grid layer that renders background grid lines
14#[derive(Debug)]
15pub struct GridLayer {
16    enabled: bool,
17    show_major_lines: bool,
18    show_minor_lines: bool,
19    auto_spacing: bool,
20    major_spacing_pixels: f32,
21    minor_divisions: u32,
22    needs_render: bool,
23}
24
25impl GridLayer {
26    /// Create a new grid layer with default settings
27    pub fn new() -> Self {
28        Self {
29            enabled: true,
30            show_major_lines: true,
31            show_minor_lines: false, // Disable minor lines
32            auto_spacing: true,
33            major_spacing_pixels: 50.0,
34            minor_divisions: 5,
35            needs_render: true,
36        }
37    }
38
39    /// Enable or disable major grid lines
40    pub fn set_show_major_lines(&mut self, show: bool) {
41        if self.show_major_lines != show {
42            self.show_major_lines = show;
43            self.needs_render = true;
44        }
45    }
46
47    /// Enable or disable minor grid lines
48    pub fn set_show_minor_lines(&mut self, show: bool) {
49        if self.show_minor_lines != show {
50            self.show_minor_lines = show;
51            self.needs_render = true;
52        }
53    }
54
55    /// Set whether to automatically calculate grid spacing
56    pub fn set_auto_spacing(&mut self, auto: bool) {
57        if self.auto_spacing != auto {
58            self.auto_spacing = auto;
59            self.needs_render = true;
60        }
61    }
62
63    /// Set major grid line spacing in pixels
64    pub fn set_major_spacing_pixels(&mut self, spacing: f32) {
65        if (self.major_spacing_pixels - spacing).abs() > 0.1 {
66            self.major_spacing_pixels = spacing;
67            self.needs_render = true;
68        }
69    }
70
71    /// Render horizontal grid lines
72    fn render_horizontal_lines(
73        &self,
74        context: &mut RenderContext,
75        viewport: &Viewport,
76        theme: &ChartTheme,
77    ) {
78        let content_rect = viewport.chart_content_rect();
79        let chart_bounds = &viewport.chart_bounds;
80
81        // Use shared utility to calculate price levels - MUST match price axis exactly
82        let price_levels = crate::utils::calculate_price_levels(
83            chart_bounds.price_min,
84            chart_bounds.price_max,
85            content_rect.height,
86        );
87
88        // Draw horizontal grid lines for each price level
89        for &price_level in &price_levels {
90            // Convert price to screen coordinates
91            let chart_pos = glam::Vec2::new(0.0, price_level as f32);
92            let screen_pos = viewport.chart_to_screen(chart_pos);
93
94            if screen_pos.y >= content_rect.y
95                && screen_pos.y <= content_rect.y + content_rect.height
96            {
97                let color = theme.colors.grid_major.with_alpha(0.35);
98
99                context.draw_line(
100                    [content_rect.x, screen_pos.y],
101                    [content_rect.x + content_rect.width, screen_pos.y],
102                    color,
103                    1.0,
104                );
105            }
106        }
107    }
108
109    /// Render vertical grid lines
110    fn render_vertical_lines(
111        &self,
112        context: &mut RenderContext,
113        viewport: &Viewport,
114        theme: &ChartTheme,
115    ) {
116        let content_rect = viewport.chart_content_rect();
117        let chart_bounds = &viewport.chart_bounds;
118
119        // Calculate optimal spacing for time grid lines - match the axis spacing
120        let time_range_seconds = chart_bounds.time_duration().num_seconds() as f64;
121        let target_label_count = (content_rect.width / theme.spacing.grid_spacing_min) as i32;
122        let time_step_seconds = time_range_seconds / target_label_count as f64;
123
124        // Find nice round time intervals
125        let nice_time_step = self.find_nice_time_interval(time_step_seconds);
126        let start_timestamp = ((chart_bounds.time_start.timestamp() as f64 / nice_time_step)
127            .floor()
128            * nice_time_step) as i64;
129
130        let mut current_timestamp = start_timestamp;
131
132        while current_timestamp <= chart_bounds.time_end.timestamp() {
133            // Convert timestamp to screen coordinates
134            let chart_pos = Vec2::new(current_timestamp as f32, 0.0);
135            let screen_pos = viewport.chart_to_screen(chart_pos);
136
137            if screen_pos.x >= content_rect.x && screen_pos.x <= content_rect.x + content_rect.width
138            {
139                let color = theme.colors.grid_major.with_alpha(0.35);
140                context.draw_line(
141                    [screen_pos.x, content_rect.y],
142                    [screen_pos.x, content_rect.y + content_rect.height],
143                    color,
144                    1.0,
145                );
146            }
147
148            current_timestamp += nice_time_step as i64;
149        }
150    }
151
152    /// Find a nice time interval in seconds
153    fn find_nice_time_interval(&self, seconds: f64) -> f64 {
154        // Common time intervals in seconds
155        let intervals = [
156            1.0,        // 1 second
157            5.0,        // 5 seconds
158            10.0,       // 10 seconds
159            15.0,       // 15 seconds
160            30.0,       // 30 seconds
161            60.0,       // 1 minute
162            300.0,      // 5 minutes
163            600.0,      // 10 minutes
164            900.0,      // 15 minutes
165            1800.0,     // 30 minutes
166            3600.0,     // 1 hour
167            14400.0,    // 4 hours
168            28800.0,    // 8 hours
169            43200.0,    // 12 hours
170            86400.0,    // 1 day
171            604800.0,   // 1 week
172            2592000.0,  // 30 days (approximate month)
173            7776000.0,  // 90 days (approximate quarter)
174            31536000.0, // 1 year
175        ];
176
177        // Find the closest interval
178        intervals
179            .iter()
180            .min_by(|&&a, &&b| {
181                let diff_a = (a - seconds).abs();
182                let diff_b = (b - seconds).abs();
183                diff_a
184                    .partial_cmp(&diff_b)
185                    .unwrap_or(std::cmp::Ordering::Equal)
186            })
187            .copied()
188            .unwrap_or(seconds)
189    }
190}
191
192impl Default for GridLayer {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198impl Layer for GridLayer {
199    fn name(&self) -> &str {
200        "Grid"
201    }
202
203    fn stage(&self) -> LayerStage {
204        LayerStage::ChartUnderlay
205    }
206
207    fn update(
208        &mut self,
209        _data: &ChartData,
210        _viewport: &Viewport,
211        _theme: &ChartTheme,
212        _style: &ChartStyle,
213    ) {
214        // Grid doesn't depend on data, but we mark as needing render on viewport/theme changes
215        self.needs_render = true;
216    }
217
218    fn render(
219        &self,
220        context: &mut RenderContext,
221        _render_pass: &mut wgpu::RenderPass,
222    ) -> Result<()> {
223        if !self.enabled {
224            return Ok(());
225        }
226
227        let viewport = context.viewport().clone();
228        let theme = context.theme().clone();
229
230        // Render both vertical and horizontal lines
231        if self.show_major_lines || self.show_minor_lines {
232            self.render_vertical_lines(context, &viewport, &theme);
233            self.render_horizontal_lines(context, &viewport, &theme);
234        }
235
236        Ok(())
237    }
238
239    fn needs_render(&self) -> bool {
240        self.needs_render
241    }
242
243    fn z_order(&self) -> i32 {
244        -100 // Render in background
245    }
246
247    fn is_enabled(&self) -> bool {
248        self.enabled
249    }
250
251    fn set_enabled(&mut self, enabled: bool) {
252        if self.enabled != enabled {
253            self.enabled = enabled;
254            self.needs_render = true;
255        }
256    }
257
258    fn get_config(&self) -> Value {
259        serde_json::json!({
260            "show_major_lines": self.show_major_lines,
261            "show_minor_lines": self.show_minor_lines,
262            "auto_spacing": self.auto_spacing,
263            "major_spacing_pixels": self.major_spacing_pixels,
264            "minor_divisions": self.minor_divisions
265        })
266    }
267
268    fn set_config(&mut self, config: Value) -> Result<()> {
269        if let Some(show_major) = config.get("show_major_lines").and_then(|v| v.as_bool()) {
270            self.set_show_major_lines(show_major);
271        }
272        if let Some(show_minor) = config.get("show_minor_lines").and_then(|v| v.as_bool()) {
273            self.set_show_minor_lines(show_minor);
274        }
275        if let Some(auto_spacing) = config.get("auto_spacing").and_then(|v| v.as_bool()) {
276            self.set_auto_spacing(auto_spacing);
277        }
278        if let Some(spacing) = config.get("major_spacing_pixels").and_then(|v| v.as_f64()) {
279            self.set_major_spacing_pixels(spacing as f32);
280        }
281        if let Some(divisions) = config.get("minor_divisions").and_then(|v| v.as_u64()) {
282            self.minor_divisions = divisions as u32;
283            self.needs_render = true;
284        }
285
286        Ok(())
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_grid_layer_creation() {
296        let layer = GridLayer::new();
297        assert_eq!(layer.name(), "Grid");
298        assert!(layer.is_enabled());
299        assert!(layer.needs_render());
300        assert_eq!(layer.z_order(), -100);
301    }
302
303    #[test]
304    fn test_grid_layer_configuration() {
305        let mut layer = GridLayer::new();
306
307        layer.set_show_major_lines(false);
308        assert!(!layer.show_major_lines);
309        assert!(layer.needs_render());
310
311        layer.set_auto_spacing(false);
312        assert!(!layer.auto_spacing);
313
314        layer.set_major_spacing_pixels(100.0);
315        assert_eq!(layer.major_spacing_pixels, 100.0);
316    }
317
318    #[test]
319    fn test_time_interval_calculation() {
320        let layer = GridLayer::new();
321
322        assert_eq!(layer.find_nice_time_interval(3.0), 1.0); // Rounds to 1 second
323        assert_eq!(layer.find_nice_time_interval(50.0), 60.0); // Rounds to 1 minute
324        assert_eq!(layer.find_nice_time_interval(400.0), 300.0); // Rounds to 5 minutes
325        assert_eq!(layer.find_nice_time_interval(3000.0), 3600.0); // Rounds to 1 hour
326    }
327}