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}