Skip to main content

shape_viz_core/layers/
range_bar.rs

1//! Range bar rendering layer
2//!
3//! Renders range data as bars (candlesticks, box plots, error bars, etc.)
4//! This is a pure geometry layer with no domain knowledge.
5
6use crate::data::{ChartData, RangeSeries};
7use crate::error::Result;
8use crate::layers::{Layer, LayerStage};
9use crate::renderer::RenderContext;
10use crate::style::ChartStyle;
11use crate::theme::ChartTheme;
12use crate::viewport::{Rect, Viewport};
13
14/// Visual style for range bars
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum RangeBarStyle {
17    /// Traditional candlestick style (body + wicks)
18    #[default]
19    Candlestick,
20    /// Box plot style (box + whiskers)
21    BoxPlot,
22    /// Error bar style (center line + error bars)
23    ErrorBar,
24    /// Filled area between min and max
25    RangeArea,
26}
27
28/// Configuration for range bar appearance
29#[derive(Debug, Clone)]
30pub struct RangeBarConfig {
31    /// Visual style
32    pub style: RangeBarStyle,
33    /// Width factor for bar bodies (0.0-1.0, relative to time spacing)
34    pub body_width_factor: f32,
35    /// Width of lines (wicks, whiskers) in pixels
36    pub line_width: f32,
37    /// Minimum bar width in pixels
38    pub min_width: f32,
39    /// Maximum bar width in pixels
40    pub max_width: f32,
41}
42
43impl Default for RangeBarConfig {
44    fn default() -> Self {
45        Self {
46            style: RangeBarStyle::default(),
47            body_width_factor: 0.7,
48            line_width: 1.5,
49            min_width: 1.5,
50            max_width: 40.0,
51        }
52    }
53}
54
55/// Cached geometry for a single range bar
56#[derive(Debug, Clone)]
57struct RangeBarGeometry {
58    pub body_rect: Rect,
59    pub line_top: (f32, f32, f32, f32), // (x1, y1, x2, y2)
60    pub line_bottom: (f32, f32, f32, f32),
61    /// True if end >= start (e.g., close >= open for candlesticks)
62    pub is_positive: bool,
63    /// True if the range is very small relative to overall range
64    pub is_neutral: bool,
65}
66
67/// Layer for rendering range bar charts (candlesticks, box plots, etc.)
68#[derive(Debug)]
69pub struct RangeBarLayer {
70    enabled: bool,
71    needs_render: bool,
72    config: RangeBarConfig,
73    cached_bars: Vec<RangeBarGeometry>,
74    last_viewport_hash: u64,
75    /// Optional range series provided directly (for non-ChartData usage)
76    range_data: Option<RangeData>,
77}
78
79/// Internal storage for range data when provided directly
80#[derive(Debug, Clone)]
81struct RangeData {
82    timestamps: Vec<f64>,
83    ranges: Vec<(f64, f64, f64, f64)>, // (start, max, min, end)
84    _auxiliary: Option<Vec<f64>>,
85}
86
87impl RangeBarLayer {
88    pub fn new() -> Self {
89        Self {
90            enabled: true,
91            needs_render: true,
92            config: RangeBarConfig::default(),
93            cached_bars: Vec::new(),
94            last_viewport_hash: 0,
95            range_data: None,
96        }
97    }
98
99    pub fn with_config(config: RangeBarConfig) -> Self {
100        Self {
101            config,
102            ..Self::new()
103        }
104    }
105
106    pub fn with_style(style: RangeBarStyle) -> Self {
107        Self {
108            config: RangeBarConfig {
109                style,
110                ..Default::default()
111            },
112            ..Self::new()
113        }
114    }
115
116    /// Set range data directly (for use without ChartData)
117    pub fn set_range_data(
118        &mut self,
119        timestamps: Vec<f64>,
120        ranges: Vec<(f64, f64, f64, f64)>,
121        auxiliary: Option<Vec<f64>>,
122    ) {
123        self.range_data = Some(RangeData {
124            timestamps,
125            ranges,
126            _auxiliary: auxiliary,
127        });
128        self.last_viewport_hash = 0; // Force recalculation
129    }
130
131    /// Calculate viewport hash for cache invalidation
132    fn viewport_hash(viewport: &Viewport) -> u64 {
133        use std::collections::hash_map::DefaultHasher;
134        use std::hash::{Hash, Hasher};
135
136        let mut hasher = DefaultHasher::new();
137
138        viewport.screen_rect.x.to_bits().hash(&mut hasher);
139        viewport.screen_rect.y.to_bits().hash(&mut hasher);
140        viewport.screen_rect.width.to_bits().hash(&mut hasher);
141        viewport.screen_rect.height.to_bits().hash(&mut hasher);
142        viewport
143            .chart_bounds
144            .time_start
145            .timestamp()
146            .hash(&mut hasher);
147        viewport.chart_bounds.time_end.timestamp().hash(&mut hasher);
148        viewport.chart_bounds.price_min.to_bits().hash(&mut hasher);
149        viewport.chart_bounds.price_max.to_bits().hash(&mut hasher);
150
151        hasher.finish()
152    }
153
154    /// Calculate geometry from internal range data
155    fn calculate_geometry_from_data(&mut self, viewport: &Viewport, style: &ChartStyle) {
156        self.cached_bars.clear();
157
158        let data = match &self.range_data {
159            Some(d) => d,
160            None => return,
161        };
162
163        if data.timestamps.is_empty() {
164            return;
165        }
166
167        let count = data.timestamps.len();
168
169        // Calculate average time spacing for width calculation
170        let time_spacing = if count > 1 {
171            let first_time = data.timestamps[0] as f32;
172            let last_time = data.timestamps[count - 1] as f32;
173            (last_time - first_time) / (count - 1) as f32
174        } else {
175            3600.0
176        };
177
178        let content_rect = viewport.layout.main_panel;
179        let time_scale =
180            content_rect.width / (viewport.chart_bounds.time_duration().num_seconds() as f32);
181        let screen_time_width = time_spacing * time_scale;
182
183        let body_width = (screen_time_width * self.config.body_width_factor)
184            .max(self.config.min_width)
185            .min(self.config.max_width);
186
187        let half_body_width = body_width * 0.5;
188
189        for i in 0..count {
190            let timestamp = data.timestamps[i];
191            let (start_val, max_val, min_val, end_val) = data.ranges[i];
192
193            // Map timestamp to screen X
194            let delta_sec =
195                (timestamp - viewport.chart_bounds.time_start.timestamp() as f64) as f32;
196            let x = content_rect.x + delta_sec * time_scale;
197
198            let y_start = viewport.chart_to_screen_y(start_val as f32);
199            let y_max = viewport.chart_to_screen_y(max_val as f32);
200            let y_min = viewport.chart_to_screen_y(min_val as f32);
201            let y_end = viewport.chart_to_screen_y(end_val as f32);
202
203            let is_positive = end_val >= start_val;
204            let range = (max_val - min_val).abs().max(1e-9);
205            let body_span = (end_val - start_val).abs();
206            let is_neutral = (body_span / range) < 0.05;
207
208            let body_top = y_start.min(y_end);
209            let body_bottom = y_start.max(y_end);
210            let body_height = body_bottom - body_top;
211
212            let min_body_height = style.candles.min_body_height;
213            let adjusted_body_height = body_height.max(min_body_height);
214            let body_y = if body_height < min_body_height {
215                (body_top + body_bottom - adjusted_body_height) * 0.5
216            } else {
217                body_top
218            };
219
220            let body_rect = Rect::new(
221                x - half_body_width,
222                body_y,
223                body_width,
224                adjusted_body_height,
225            );
226
227            let line_top = if y_max < body_top {
228                (x, y_max, x, body_top)
229            } else {
230                (x, y_max, x, y_max)
231            };
232
233            let line_bottom = if y_min > body_bottom {
234                (x, body_bottom, x, y_min)
235            } else {
236                (x, y_min, x, y_min)
237            };
238
239            self.cached_bars.push(RangeBarGeometry {
240                body_rect,
241                line_top,
242                line_bottom,
243                is_positive,
244                is_neutral,
245            });
246        }
247    }
248
249    /// Calculate geometry from a RangeSeries
250    fn _calculate_geometry_from_series<S: RangeSeries + ?Sized>(
251        &mut self,
252        series: &S,
253        data: &ChartData,
254        viewport: &Viewport,
255        _theme: &ChartTheme,
256        style: &ChartStyle,
257    ) {
258        self.cached_bars.clear();
259
260        let (start_idx, end_idx) = match data.visible_indices() {
261            Some((start, end)) => (start, end),
262            None => (0, series.len()),
263        };
264
265        if start_idx >= end_idx {
266            return;
267        }
268
269        let count = end_idx - start_idx;
270
271        let time_spacing = if count > 1 {
272            let first_time = series.get_x(start_idx) as f32;
273            let last_time = series.get_x(end_idx - 1) as f32;
274            (last_time - first_time) / (count - 1) as f32
275        } else {
276            3600.0
277        };
278
279        let content_rect = viewport.layout.main_panel;
280        let time_scale =
281            content_rect.width / (viewport.chart_bounds.time_duration().num_seconds() as f32);
282        let screen_time_width = time_spacing * time_scale;
283
284        let body_width = (screen_time_width * self.config.body_width_factor)
285            .max(self.config.min_width)
286            .min(self.config.max_width);
287
288        let half_body_width = body_width * 0.5;
289
290        for i in start_idx..end_idx {
291            let (start_val, max_val, min_val, end_val) = series.get_range(i);
292            let timestamp_f64 = series.get_x(i);
293
294            let delta_sec =
295                (timestamp_f64 - viewport.chart_bounds.time_start.timestamp() as f64) as f32;
296            let x = content_rect.x + delta_sec * time_scale;
297
298            let y_start = viewport.chart_to_screen_y(start_val as f32);
299            let y_max = viewport.chart_to_screen_y(max_val as f32);
300            let y_min = viewport.chart_to_screen_y(min_val as f32);
301            let y_end = viewport.chart_to_screen_y(end_val as f32);
302
303            let is_positive = end_val >= start_val;
304            let range = (max_val - min_val).abs().max(1e-9);
305            let body_span = (end_val - start_val).abs();
306            let is_neutral = (body_span / range) < 0.05;
307
308            let body_top = y_start.min(y_end);
309            let body_bottom = y_start.max(y_end);
310            let body_height = body_bottom - body_top;
311
312            let min_body_height = style.candles.min_body_height;
313            let adjusted_body_height = body_height.max(min_body_height);
314            let body_y = if body_height < min_body_height {
315                (body_top + body_bottom - adjusted_body_height) * 0.5
316            } else {
317                body_top
318            };
319
320            let body_rect = Rect::new(
321                x - half_body_width,
322                body_y,
323                body_width,
324                adjusted_body_height,
325            );
326
327            let line_top = if y_max < body_top {
328                (x, y_max, x, body_top)
329            } else {
330                (x, y_max, x, y_max)
331            };
332
333            let line_bottom = if y_min > body_bottom {
334                (x, body_bottom, x, y_min)
335            } else {
336                (x, y_min, x, y_min)
337            };
338
339            self.cached_bars.push(RangeBarGeometry {
340                body_rect,
341                line_top,
342                line_bottom,
343                is_positive,
344                is_neutral,
345            });
346        }
347    }
348
349    /// Render cached geometry
350    fn render_cached_geometry(
351        &self,
352        context: &mut RenderContext,
353        theme: &ChartTheme,
354    ) -> Result<()> {
355        let content_rect = context.viewport().chart_content_rect();
356
357        for bar in &self.cached_bars {
358            if bar.body_rect.x + bar.body_rect.width < content_rect.x
359                || bar.body_rect.x > content_rect.x + content_rect.width
360                || bar.body_rect.y + bar.body_rect.height < content_rect.y
361                || bar.body_rect.y > content_rect.y + content_rect.height
362            {
363                continue;
364            }
365
366            let body_color = if bar.is_neutral {
367                theme.colors.candle_doji
368            } else if bar.is_positive {
369                theme.colors.candle_bullish
370            } else {
371                theme.colors.candle_bearish
372            };
373
374            let line_color = if bar.is_neutral {
375                theme.colors.wick_color
376            } else if bar.is_positive {
377                theme.colors.wick_bullish
378            } else {
379                theme.colors.wick_bearish
380            };
381
382            context.draw_rect(bar.body_rect, body_color);
383
384            let (x1, y1, x2, y2) = bar.line_top;
385            if (y2 - y1).abs() > 0.1 {
386                context.draw_line([x1, y1], [x2, y2], line_color, self.config.line_width);
387            }
388
389            let (x1, y1, x2, y2) = bar.line_bottom;
390            if (y2 - y1).abs() > 0.1 {
391                context.draw_line([x1, y1], [x2, y2], line_color, self.config.line_width);
392            }
393        }
394
395        Ok(())
396    }
397}
398
399impl Default for RangeBarLayer {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405impl Layer for RangeBarLayer {
406    fn name(&self) -> &str {
407        "RangeBar"
408    }
409
410    fn stage(&self) -> LayerStage {
411        LayerStage::ChartMain
412    }
413
414    fn update(
415        &mut self,
416        _data: &ChartData,
417        viewport: &Viewport,
418        _theme: &ChartTheme,
419        style: &ChartStyle,
420    ) {
421        self.config.body_width_factor = style.candles.body_width_factor;
422        self.config.line_width = style.candles.wick_width;
423        self.config.min_width = style.candles.min_body_width;
424        self.config.max_width = style.candles.max_body_width;
425
426        let viewport_hash = Self::viewport_hash(viewport);
427
428        if viewport_hash != self.last_viewport_hash {
429            // Use internal range data if set, otherwise try to use ChartData's main series
430            if self.range_data.is_some() {
431                self.calculate_geometry_from_data(viewport, style);
432            }
433            // Note: ChartData integration with RangeSeries requires the series to implement
434            // the trait - this will be connected when wire protocol integration is complete
435            self.last_viewport_hash = viewport_hash;
436        }
437
438        self.needs_render = true;
439    }
440
441    fn render(
442        &self,
443        context: &mut RenderContext,
444        _render_pass: &mut wgpu::RenderPass,
445    ) -> Result<()> {
446        if !self.cached_bars.is_empty() {
447            let theme = context.theme().clone();
448            self.render_cached_geometry(context, &theme)?;
449        }
450        Ok(())
451    }
452
453    fn needs_render(&self) -> bool {
454        self.needs_render
455    }
456
457    fn z_order(&self) -> i32 {
458        1
459    }
460
461    fn is_enabled(&self) -> bool {
462        self.enabled
463    }
464
465    fn set_enabled(&mut self, enabled: bool) {
466        self.enabled = enabled;
467        self.needs_render = true;
468    }
469}
470
471// Type aliases for backwards compatibility
472pub type CandlestickLayer = RangeBarLayer;
473pub type CandlestickConfig = RangeBarConfig;