Skip to main content

shape_viz_core/
viewport.rs

1//! Viewport and coordinate system management
2
3use crate::error::{ChartError, Result};
4use crate::style::LayoutStyle;
5use chrono::{DateTime, Utc};
6use glam::{Mat3, Vec2};
7use serde::{Deserialize, Serialize};
8
9/// 2D rectangle representing screen or chart bounds
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
11pub struct Rect {
12    pub x: f32,
13    pub y: f32,
14    pub width: f32,
15    pub height: f32,
16}
17
18impl Rect {
19    pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
20        Self {
21            x,
22            y,
23            width,
24            height,
25        }
26    }
27
28    pub fn from_size(width: f32, height: f32) -> Self {
29        Self::new(0.0, 0.0, width, height)
30    }
31
32    pub fn right(&self) -> f32 {
33        self.x + self.width
34    }
35
36    pub fn bottom(&self) -> f32 {
37        self.y + self.height
38    }
39
40    pub fn center(&self) -> Vec2 {
41        Vec2::new(self.x + self.width * 0.5, self.y + self.height * 0.5)
42    }
43
44    pub fn contains_point(&self, point: Vec2) -> bool {
45        point.x >= self.x
46            && point.x <= self.right()
47            && point.y >= self.y
48            && point.y <= self.bottom()
49    }
50
51    pub fn intersects(&self, other: &Rect) -> bool {
52        self.x < other.right()
53            && self.right() > other.x
54            && self.y < other.bottom()
55            && self.bottom() > other.y
56    }
57
58    /// Shrink rectangle by given margins
59    pub fn shrink(&self, margin: f32) -> Rect {
60        Rect::new(
61            self.x + margin,
62            self.y + margin,
63            (self.width - 2.0 * margin).max(0.0),
64            (self.height - 2.0 * margin).max(0.0),
65        )
66    }
67
68    /// Split rectangle horizontally into two parts
69    pub fn split_horizontal(&self, ratio: f32) -> (Rect, Rect) {
70        let split_y = self.y + self.height * ratio.clamp(0.0, 1.0);
71        let top_height = split_y - self.y;
72        let bottom_height = self.bottom() - split_y;
73
74        let top = Rect::new(self.x, self.y, self.width, top_height);
75        let bottom = Rect::new(self.x, split_y, self.width, bottom_height);
76
77        (top, bottom)
78    }
79
80    /// Calculate the intersection of two rectangles
81    pub fn intersection(&self, other: &Rect) -> Option<Rect> {
82        let x1 = self.x.max(other.x);
83        let y1 = self.y.max(other.y);
84        let x2 = self.right().min(other.right());
85        let y2 = self.bottom().min(other.bottom());
86
87        if x2 > x1 && y2 > y1 {
88            Some(Rect::new(x1, y1, x2 - x1, y2 - y1))
89        } else {
90            None
91        }
92    }
93}
94
95/// Defines the layout of the different chart panels
96#[derive(Debug, Clone)]
97pub struct ChartLayout {
98    pub main_panel: Rect,
99    pub volume_panel: Rect,
100    pub price_axis_panel: Rect,
101    pub time_axis_panel: Rect,
102    pub full_rect: Rect,
103}
104
105impl ChartLayout {
106    /// Calculate the layout based on the full viewport rectangle and style parameters
107    pub fn new(full_rect: Rect, style: &LayoutStyle) -> Self {
108        let price_axis_width = style.price_axis_width.max(40.0);
109        let time_axis_height = style.time_axis_height.max(24.0);
110        let volume_height_ratio = style.volume_height_ratio.clamp(0.05, 0.5);
111        let volume_gap = style.volume_gap.max(0.0);
112        let chart_padding_x = style.chart_padding_x.max(0.0);
113        let chart_padding_y = style.chart_padding_y.max(0.0);
114
115        let chart_area_x = full_rect.x + chart_padding_x;
116        let chart_area_y = full_rect.y + chart_padding_y;
117        let chart_area_width =
118            (full_rect.width - price_axis_width - 2.0 * chart_padding_x).max(1.0);
119        let chart_area_height =
120            (full_rect.height - time_axis_height - 2.0 * chart_padding_y).max(1.0);
121
122        let volume_height = (chart_area_height * volume_height_ratio).max(24.0);
123        let main_height = (chart_area_height - volume_height - volume_gap).max(1.0);
124
125        let main_panel = Rect::new(chart_area_x, chart_area_y, chart_area_width, main_height);
126
127        let volume_panel = Rect::new(
128            chart_area_x,
129            chart_area_y + main_height + volume_gap,
130            chart_area_width,
131            volume_height,
132        );
133
134        let price_axis_panel = Rect::new(
135            chart_area_x + chart_area_width,
136            chart_area_y,
137            price_axis_width,
138            main_height,
139        );
140
141        let time_axis_panel = Rect::new(
142            full_rect.x,
143            volume_panel.y + volume_panel.height,
144            full_rect.width,
145            time_axis_height,
146        );
147
148        Self {
149            main_panel,
150            volume_panel,
151            price_axis_panel,
152            time_axis_panel,
153            full_rect,
154        }
155    }
156}
157
158/// Chart bounds in data space (time and price coordinates)
159#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
160pub struct ChartBounds {
161    pub time_start: DateTime<Utc>,
162    pub time_end: DateTime<Utc>,
163    pub price_min: f64,
164    pub price_max: f64,
165}
166
167impl ChartBounds {
168    pub fn new(
169        time_start: DateTime<Utc>,
170        time_end: DateTime<Utc>,
171        price_min: f64,
172        price_max: f64,
173    ) -> Result<Self> {
174        if time_start >= time_end {
175            return Err(ChartError::data_range("Start time must be before end time"));
176        }
177
178        if price_min >= price_max {
179            return Err(ChartError::data_range(
180                "Min price must be less than max price",
181            ));
182        }
183
184        Ok(Self {
185            time_start,
186            time_end,
187            price_min,
188            price_max,
189        })
190    }
191
192    pub fn time_duration(&self) -> chrono::Duration {
193        self.time_end - self.time_start
194    }
195
196    pub fn price_range(&self) -> f64 {
197        self.price_max - self.price_min
198    }
199
200    pub fn contains_time(&self, time: DateTime<Utc>) -> bool {
201        time >= self.time_start && time <= self.time_end
202    }
203
204    pub fn contains_price(&self, price: f64) -> bool {
205        price >= self.price_min && price <= self.price_max
206    }
207
208    /// Expand bounds to include the given time and price
209    pub fn expand_to_include(&mut self, time: DateTime<Utc>, price: f64) {
210        if time < self.time_start {
211            self.time_start = time;
212        }
213        if time > self.time_end {
214            self.time_end = time;
215        }
216        if price < self.price_min {
217            self.price_min = price;
218        }
219        if price > self.price_max {
220            self.price_max = price;
221        }
222    }
223
224    /// Add padding to the bounds (percentage of current range)
225    pub fn with_padding(&self, time_padding: f64, price_padding: f64) -> Result<Self> {
226        let time_range_seconds = self.time_duration().num_seconds() as f64;
227        let time_padding_seconds = (time_range_seconds * time_padding) as i64;
228
229        let price_range = self.price_range();
230        let price_padding_amount = price_range * price_padding;
231
232        ChartBounds::new(
233            self.time_start - chrono::Duration::seconds(time_padding_seconds),
234            self.time_end + chrono::Duration::seconds(time_padding_seconds),
235            self.price_min - price_padding_amount,
236            self.price_max + price_padding_amount,
237        )
238    }
239}
240
241/// Viewport manages coordinate transformations between chart data space and screen space
242#[derive(Debug, Clone)]
243pub struct Viewport {
244    /// Screen rectangle where the chart is rendered
245    pub screen_rect: Rect,
246    /// Chart data bounds
247    pub chart_bounds: ChartBounds,
248    /// Layout of the chart panels
249    pub layout: ChartLayout,
250    layout_style: LayoutStyle,
251    /// Transformation matrix from chart space to screen space
252    transform: Mat3,
253    /// Inverse transformation matrix from screen space to chart space
254    inverse_transform: Mat3,
255}
256
257impl Viewport {
258    pub fn new(screen_rect: Rect, chart_bounds: ChartBounds, layout_style: LayoutStyle) -> Self {
259        let layout = ChartLayout::new(screen_rect, &layout_style);
260        let mut viewport = Self {
261            screen_rect,
262            chart_bounds,
263            layout,
264            layout_style,
265            transform: Mat3::IDENTITY,
266            inverse_transform: Mat3::IDENTITY,
267        };
268        viewport.update_transforms();
269        viewport
270    }
271
272    /// Get the chart content area (main panel)
273    pub fn chart_content_rect(&self) -> Rect {
274        self.layout.main_panel
275    }
276
277    /// Get the price axis area
278    pub fn price_axis_rect(&self) -> Rect {
279        self.layout.price_axis_panel
280    }
281
282    /// Get the time axis area
283    pub fn time_axis_rect(&self) -> Rect {
284        self.layout.time_axis_panel
285    }
286
287    /// Get the volume area
288    pub fn volume_rect(&self) -> Rect {
289        self.layout.volume_panel
290    }
291
292    /// Update the screen rectangle
293    pub fn set_screen_rect(&mut self, rect: Rect) {
294        self.screen_rect = rect;
295        self.layout = ChartLayout::new(rect, &self.layout_style);
296        self.update_transforms();
297    }
298
299    /// Update layout style parameters and recompute layout
300    pub fn set_layout_style(&mut self, style: LayoutStyle) {
301        self.layout_style = style;
302        self.layout = ChartLayout::new(self.screen_rect, &self.layout_style);
303        self.update_transforms();
304    }
305
306    /// Update the chart bounds
307    pub fn set_chart_bounds(&mut self, bounds: ChartBounds) {
308        self.chart_bounds = bounds;
309        self.update_transforms();
310    }
311
312    /// Pan the viewport by screen space delta
313    pub fn pan(&mut self, screen_delta: Vec2) {
314        // Convert screen delta to chart space delta
315        let chart_delta = self.screen_to_chart_delta(screen_delta);
316
317        // Create new bounds with the pan applied
318        let time_delta_seconds = chart_delta.x as i64;
319        let price_delta = chart_delta.y as f64;
320
321        if let Ok(new_bounds) = ChartBounds::new(
322            self.chart_bounds.time_start + chrono::Duration::seconds(time_delta_seconds),
323            self.chart_bounds.time_end + chrono::Duration::seconds(time_delta_seconds),
324            self.chart_bounds.price_min + price_delta,
325            self.chart_bounds.price_max + price_delta,
326        ) {
327            self.chart_bounds = new_bounds;
328            self.update_transforms();
329        }
330    }
331
332    /// Zoom the viewport around a center point in screen space
333    pub fn zoom(&mut self, center_screen: Vec2, zoom_factor: f32) {
334        // Convert center to chart space
335        let center_chart = self.screen_to_chart(center_screen);
336
337        // Calculate new ranges
338        let time_range = self.chart_bounds.time_duration().num_seconds() as f64;
339        let price_range = self.chart_bounds.price_range();
340
341        let new_time_range = time_range / zoom_factor as f64;
342        let new_price_range = price_range / zoom_factor as f64;
343
344        // Calculate new bounds centered around the zoom point
345        let time_center_offset =
346            (center_chart.x as f64 - self.chart_bounds.time_start.timestamp() as f64) / time_range;
347        let price_center_offset =
348            (center_chart.y as f64 - self.chart_bounds.price_min) / price_range;
349
350        let new_time_start = center_chart.x as i64 - (new_time_range * time_center_offset) as i64;
351        let new_time_end =
352            center_chart.x as i64 + (new_time_range * (1.0 - time_center_offset)) as i64;
353
354        let new_price_min = center_chart.y as f64 - new_price_range * price_center_offset;
355        let new_price_max = center_chart.y as f64 + new_price_range * (1.0 - price_center_offset);
356
357        if let (Some(start_time), Some(end_time)) = (
358            DateTime::from_timestamp(new_time_start, 0),
359            DateTime::from_timestamp(new_time_end, 0),
360        ) {
361            if let Ok(new_bounds) =
362                ChartBounds::new(start_time, end_time, new_price_min, new_price_max)
363            {
364                self.chart_bounds = new_bounds;
365                self.update_transforms();
366            }
367        }
368    }
369
370    /// Convert chart coordinates (timestamp, price) to screen coordinates
371    pub fn chart_to_screen(&self, chart_pos: Vec2) -> Vec2 {
372        let homogeneous = self.transform * chart_pos.extend(1.0);
373        Vec2::new(homogeneous.x, homogeneous.y)
374    }
375
376    /// Convert screen coordinates to chart coordinates (timestamp, price)
377    pub fn screen_to_chart(&self, screen_pos: Vec2) -> Vec2 {
378        let homogeneous = self.inverse_transform * screen_pos.extend(1.0);
379        Vec2::new(homogeneous.x, homogeneous.y)
380    }
381
382    /// Convert screen space delta to chart space delta
383    pub fn screen_to_chart_delta(&self, screen_delta: Vec2) -> Vec2 {
384        let origin = self.screen_to_chart(Vec2::ZERO);
385        let target = self.screen_to_chart(screen_delta);
386        target - origin
387    }
388
389    /// Check if a chart position is visible in the current viewport
390    pub fn is_chart_pos_visible(&self, chart_pos: Vec2) -> bool {
391        let screen_pos = self.chart_to_screen(chart_pos);
392        self.screen_rect.contains_point(screen_pos)
393    }
394
395    /// Get the visible time range as timestamps
396    pub fn visible_time_range(&self) -> (i64, i64) {
397        (
398            self.chart_bounds.time_start.timestamp(),
399            self.chart_bounds.time_end.timestamp(),
400        )
401    }
402
403    /// Get the visible price range
404    pub fn visible_price_range(&self) -> (f64, f64) {
405        (self.chart_bounds.price_min, self.chart_bounds.price_max)
406    }
407
408    /// Convert chart X coordinate (timestamp) to screen X coordinate
409    pub fn chart_to_screen_x(&self, chart_x: f32) -> f32 {
410        let chart_pos = Vec2::new(chart_x, 0.0);
411        let screen_pos = self.chart_to_screen(chart_pos);
412        screen_pos.x
413    }
414
415    /// Convert chart Y coordinate (price) to screen Y coordinate
416    pub fn chart_to_screen_y(&self, chart_y: f32) -> f32 {
417        let chart_pos = Vec2::new(0.0, chart_y);
418        let screen_pos = self.chart_to_screen(chart_pos);
419        screen_pos.y
420    }
421
422    /// Convert chart distance in X direction to screen distance
423    pub fn chart_to_screen_distance_x(&self, chart_distance: f32) -> f32 {
424        let origin = self.chart_to_screen(Vec2::ZERO);
425        let target = self.chart_to_screen(Vec2::new(chart_distance, 0.0));
426        (target.x - origin.x).abs()
427    }
428
429    /// Convert chart distance in Y direction to screen distance
430    pub fn chart_to_screen_distance_y(&self, chart_distance: f32) -> f32 {
431        let origin = self.chart_to_screen(Vec2::ZERO);
432        let target = self.chart_to_screen(Vec2::new(0.0, chart_distance));
433        (target.y - origin.y).abs()
434    }
435
436    /// Update the transformation matrices
437    fn update_transforms(&mut self) {
438        // Use the main panel for transformations
439        let content_rect = self.layout.main_panel;
440
441        // Calculate scale factors
442        let time_scale =
443            content_rect.width / (self.chart_bounds.time_duration().num_seconds() as f32);
444        let price_scale = -content_rect.height / (self.chart_bounds.price_range() as f32); // Negative because screen Y increases downward
445
446        // Calculate translation
447        let time_translate =
448            content_rect.x - (self.chart_bounds.time_start.timestamp() as f32 * time_scale);
449        let price_translate =
450            content_rect.bottom() - (self.chart_bounds.price_min as f32 * price_scale);
451
452        // Create transformation matrix
453        self.transform = Mat3::from_translation(Vec2::new(time_translate, price_translate))
454            * Mat3::from_scale(Vec2::new(time_scale, price_scale));
455
456        // Calculate inverse transform
457        self.inverse_transform = self.transform.inverse();
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::style::LayoutStyle;
465    use chrono::TimeZone;
466
467    #[test]
468    fn test_rect_operations() {
469        let rect = Rect::new(10.0, 20.0, 100.0, 50.0);
470
471        assert_eq!(rect.right(), 110.0);
472        assert_eq!(rect.bottom(), 70.0);
473        assert_eq!(rect.center(), Vec2::new(60.0, 45.0));
474
475        assert!(rect.contains_point(Vec2::new(50.0, 40.0)));
476        assert!(!rect.contains_point(Vec2::new(5.0, 40.0)));
477    }
478
479    #[test]
480    fn test_chart_bounds() {
481        let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
482        let end = Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap();
483
484        let bounds = ChartBounds::new(start, end, 100.0, 200.0).unwrap();
485
486        assert_eq!(bounds.time_duration().num_hours(), 24);
487        assert_eq!(bounds.price_range(), 100.0);
488
489        let mid_time = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
490        assert!(bounds.contains_time(mid_time));
491        assert!(bounds.contains_price(150.0));
492    }
493
494    #[test]
495    fn test_viewport_transforms() {
496        let screen_rect = Rect::new(0.0, 0.0, 800.0, 600.0);
497        let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
498        let end = Utc.with_ymd_and_hms(2024, 1, 1, 1, 0, 0).unwrap(); // 1 hour
499        let chart_bounds = ChartBounds::new(start, end, 100.0, 200.0).unwrap();
500
501        let viewport = Viewport::new(screen_rect, chart_bounds, LayoutStyle::default());
502
503        // Test coordinate transformations
504        let chart_pos = Vec2::new(start.timestamp() as f32, 150.0);
505        let screen_pos = viewport.chart_to_screen(chart_pos);
506        let back_to_chart = viewport.screen_to_chart(screen_pos);
507
508        // Should round-trip with minimal floating point error (allowing small FP drift)
509        assert!((back_to_chart.x - chart_pos.x).abs() < 200.0);
510        assert!((back_to_chart.y - chart_pos.y).abs() < 0.01);
511    }
512}