Skip to main content

egui_charts/chart/series/
selection_render.rs

1//! Series selection rendering - visual feedback when series is selected.
2//!
3//! Selection style: small circles on the data points at regular intervals.
4
5use crate::chart::coords::ChartMapping;
6use crate::tokens::DESIGN_TOKENS;
7use egui::{Color32, Painter, Pos2};
8
9// Selection dot sizing constants (matching chart-rendering.md Handles spec)
10/// Outer radius of selection dots (donut outer circle)
11pub const SELECTION_DOT_OUTER_RADIUS: f32 = 4.5;
12/// Inner radius of selection dots (donut hole)
13pub const SELECTION_DOT_INNER_RADIUS: f32 = 2.5;
14/// Target pixel gap between selection dots (adjusts with zoom)
15pub const SELECTION_DOT_TARGET_GAP: f32 = 70.0;
16/// Minimum interval between dots (bars)
17pub const SELECTION_DOT_MIN_INTERVAL: usize = 3;
18/// Maximum interval between dots (bars)
19pub const SELECTION_DOT_MAX_INTERVAL: usize = 50;
20/// Default interval between dots (bars)
21pub const SELECTION_DOT_DEFAULT_INTERVAL: usize = 5;
22
23/// Configuration for the donut-style selection dots rendered on a selected series.
24///
25/// Selection dots appear at regular intervals along the series data to visually
26/// indicate which series is selected. They use a "donut" style: a filled outer
27/// ring with an inner circle in the chart background color.
28#[derive(Clone, Debug)]
29pub struct SelectionHandleConfig {
30    /// Outer radius of selection dots
31    pub outer_radius: f32,
32    /// Inner radius (for donut effect)
33    pub inner_radius: f32,
34    /// Color of the outer ring
35    pub ring_color: Color32,
36    /// Color of the inner circle (chart background)
37    pub inner_color: Color32,
38    /// Interval between dots (every N data points)
39    pub dot_interval: usize,
40}
41
42impl Default for SelectionHandleConfig {
43    fn default() -> Self {
44        Self {
45            outer_radius: SELECTION_DOT_OUTER_RADIUS,
46            inner_radius: SELECTION_DOT_INNER_RADIUS,
47            ring_color: DESIGN_TOKENS.semantic.extended.accent,
48            inner_color: DESIGN_TOKENS.semantic.chart.bg,
49            dot_interval: SELECTION_DOT_DEFAULT_INTERVAL,
50        }
51    }
52}
53
54/// Calculate dynamic dot interval based on bar spacing (zoom level).
55///
56/// Ensures dots are spaced roughly 60-80 pixels apart to reduce visual noise
57/// when zoomed out, while showing more dots when zoomed in.
58pub fn calculate_dot_interval(bar_spacing: f32) -> usize {
59    let interval = (SELECTION_DOT_TARGET_GAP / bar_spacing).round() as usize;
60    interval.clamp(SELECTION_DOT_MIN_INTERVAL, SELECTION_DOT_MAX_INTERVAL)
61}
62
63/// Draw a single selection dot (donut style - filled ring with inner circle)
64fn draw_selection_dot(painter: &Painter, pos: Pos2, config: &SelectionHandleConfig) {
65    // Outer filled circle
66    painter.circle_filled(pos, config.outer_radius, config.ring_color);
67    // Inner circle (chart background color) to create donut effect
68    painter.circle_filled(pos, config.inner_radius, config.inner_color);
69}
70
71/// Render selection dots on data points
72///
73/// Draws small donut-style circles directly on the plotted data at regular intervals
74/// to indicate the series is selected.
75pub fn render_series_selection_on_points(
76    painter: &Painter,
77    points: &[Pos2],
78    config: &SelectionHandleConfig,
79) {
80    if points.is_empty() {
81        return;
82    }
83
84    // Draw donut dots at regular intervals
85    for (i, &point) in points.iter().enumerate() {
86        if i % config.dot_interval == 0 {
87            draw_selection_dot(painter, point, config);
88        }
89    }
90}
91
92/// Resolve the close price for an absolute bar index against a visible-slice
93/// array.
94///
95/// `visible_range` is expressed in absolute (data-space) bar indices because
96/// [`ChartMapping::idx_to_x`] positions bars by their absolute index. The
97/// `closes` slice, however, holds only the visible bars and is therefore
98/// indexed locally: the close for absolute bar `abs_idx` lives at
99/// `closes[abs_idx - start_idx]`. Conflating the two index spaces drops every
100/// handle once the chart is scrolled past the first bar (a non-zero
101/// `start_idx`), which is why the local offset is applied explicitly here.
102///
103/// Returns `None` when `abs_idx` precedes the visible window or falls beyond the
104/// available data.
105#[inline]
106fn close_for_abs_idx(closes: &[f64], start_idx: usize, abs_idx: usize) -> Option<f64> {
107    let local = abs_idx.checked_sub(start_idx)?;
108    closes.get(local).copied()
109}
110
111/// Render selection dots on candle data points.
112///
113/// For candlestick charts, places dots at the close price of each candle at
114/// regular intervals.
115pub fn render_candle_selection_dots<F>(
116    painter: &Painter,
117    visible_range: std::ops::Range<usize>,
118    coords: &ChartMapping,
119    closes: &[f64],
120    price_to_y: F,
121    config: &SelectionHandleConfig,
122) where
123    F: Fn(f64) -> f32,
124{
125    let start_idx = visible_range.start;
126    for i in visible_range {
127        if i % config.dot_interval != 0 {
128            continue;
129        }
130
131        let Some(close) = close_for_abs_idx(closes, start_idx, i) else {
132            continue;
133        };
134
135        let x = coords.idx_to_x(i);
136        let y = price_to_y(close);
137
138        // Only draw if within chart bounds
139        if coords.rect.contains(Pos2::new(x, y)) {
140            draw_selection_dot(painter, Pos2::new(x, y), config);
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn close_lookup_resolves_for_nonzero_start_index() {
151        // Mirrors the demo: ~100 visible bars drawn from a larger series, so the
152        // visible window starts well past index 0. The `closes` slice is the
153        // local visible slice; absolute index `i` must map to `closes[i -
154        // start]`, not `closes[i]`.
155        let start_idx = 400;
156        let closes: Vec<f64> = (0..100).map(|local| 10.0 + local as f64).collect();
157        let visible_range = start_idx..start_idx + closes.len();
158
159        // Every absolute index in the visible window must resolve.
160        for abs_idx in visible_range.clone() {
161            let resolved = close_for_abs_idx(&closes, start_idx, abs_idx)
162                .expect("close must resolve for an in-window absolute index");
163            assert_eq!(resolved, closes[abs_idx - start_idx]);
164        }
165
166        for (abs_idx, expected) in [
167            (start_idx, Some(10.0)),
168            (visible_range.end - 1, Some(109.0)),
169            (start_idx - 1, None),
170            (visible_range.end, None),
171        ] {
172            assert_eq!(
173                close_for_abs_idx(&closes, start_idx, abs_idx),
174                expected,
175                "{abs_idx}"
176            );
177        }
178    }
179}