Skip to main content

egui_charts/drawings/services/
snap.rs

1//! Snap Service - magnet snapping for drawing tools
2//!
3//! Provides snap-to-price, snap-to-time, and magnet mode (snap to existing drawing points).
4//!
5//! # Features
6//!
7//! - Snap to OHLC prices of visible candles
8//! - Snap to candle open times
9//! - Magnet mode: snap to nearby existing drawing points
10
11use egui::Pos2;
12
13/// Options for snap behavior
14#[derive(Debug, Clone)]
15pub struct SnapOptions {
16    /// Enable snap to OHLC prices
17    pub snap_to_price: bool,
18    /// Enable snap to candle times
19    pub snap_to_time: bool,
20    /// Snap distance threshold in pixels
21    pub snap_distance: f32,
22    /// Enable magnet mode (snap to existing drawing points)
23    pub magnet_mode: bool,
24    /// Magnet distance threshold in pixels
25    pub magnet_distance: f32,
26}
27
28impl Default for SnapOptions {
29    fn default() -> Self {
30        Self {
31            snap_to_price: true,
32            snap_to_time: true,
33            snap_distance: 10.0,
34            magnet_mode: false,
35            magnet_distance: 15.0,
36        }
37    }
38}
39
40/// Snap targets - prices and times to snap to
41#[derive(Debug, Clone, Default)]
42pub struct SnapTargets {
43    /// Y-coordinates of OHLC prices in screen space
44    pub prices: Vec<f32>,
45    /// X-coordinates of candle times in screen space
46    pub times: Vec<f32>,
47    /// Points from existing drawings (for magnet mode)
48    pub drawing_points: Vec<Pos2>,
49}
50
51impl SnapTargets {
52    /// Create empty snap targets
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Create with price and time targets
58    pub fn with_price_time(prices: Vec<f32>, times: Vec<f32>) -> Self {
59        Self {
60            prices,
61            times,
62            drawing_points: Vec::new(),
63        }
64    }
65
66    /// Add drawing points for magnet mode
67    pub fn with_drawing_points(mut self, points: Vec<Pos2>) -> Self {
68        self.drawing_points = points;
69        self
70    }
71
72    /// Update price targets
73    pub fn set_prices(&mut self, prices: Vec<f32>) {
74        self.prices = prices;
75    }
76
77    /// Update time targets
78    pub fn set_times(&mut self, times: Vec<f32>) {
79        self.times = times;
80    }
81
82    /// Update drawing points for magnet mode
83    pub fn set_drawing_points(&mut self, points: Vec<Pos2>) {
84        self.drawing_points = points;
85    }
86
87    /// Clear all targets
88    pub fn clear(&mut self) {
89        self.prices.clear();
90        self.times.clear();
91        self.drawing_points.clear();
92    }
93}
94
95/// Snap service for applying snap behavior to points
96#[derive(Debug, Clone)]
97pub struct SnapService {
98    options: SnapOptions,
99}
100
101impl Default for SnapService {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl SnapService {
108    /// Create a new snap service with default options
109    pub fn new() -> Self {
110        Self {
111            options: SnapOptions::default(),
112        }
113    }
114
115    /// Create with custom options
116    pub fn with_options(options: SnapOptions) -> Self {
117        Self { options }
118    }
119
120    /// Get the current options
121    pub fn options(&self) -> &SnapOptions {
122        &self.options
123    }
124
125    /// Get mutable options
126    pub fn options_mut(&mut self) -> &mut SnapOptions {
127        &mut self.options
128    }
129
130    /// Apply snap to a point
131    ///
132    /// Applies in order:
133    /// 1. Snap to price (Y axis)
134    /// 2. Snap to time (X axis)
135    /// 3. Magnet mode (snap to nearest drawing point)
136    pub fn snap_point(&self, point: Pos2, targets: &SnapTargets) -> Pos2 {
137        self.snap_point_with_drawing_points(
138            point,
139            &targets.prices,
140            &targets.times,
141            targets.drawing_points.iter().copied(),
142        )
143    }
144
145    /// Apply snap to a point, sourcing magnet targets from a borrowed iterator.
146    ///
147    /// Identical behavior to [`SnapService::snap_point`], but the magnet-mode
148    /// drawing points are supplied as an iterator rather than an owned `Vec`.
149    /// This lets per-pointer-move callers (e.g. during a drag) borrow the live
150    /// drawing geometry instead of cloning every point of every drawing on each
151    /// pointer event. The iterator is only consumed when magnet mode is active.
152    pub fn snap_point_with_drawing_points<I>(
153        &self,
154        point: Pos2,
155        prices: &[f32],
156        times: &[f32],
157        drawing_points: I,
158    ) -> Pos2
159    where
160        I: IntoIterator<Item = Pos2>,
161    {
162        let mut result = point;
163
164        // Snap to price (Y axis)
165        if self.options.snap_to_price
166            && !prices.is_empty()
167            && let Some(snapped_y) = self.find_closest(prices, point.y)
168        {
169            result.y = snapped_y;
170        }
171
172        // Snap to time (X axis)
173        if self.options.snap_to_time
174            && !times.is_empty()
175            && let Some(snapped_x) = self.find_closest(times, point.x)
176        {
177            result.x = snapped_x;
178        }
179
180        // Magnet mode - snap to nearby drawing points
181        if self.options.magnet_mode
182            && let Some(closest_point) = self.find_closest_point(drawing_points, point)
183        {
184            result = closest_point;
185        }
186
187        result
188    }
189
190    /// Find closest value within snap distance. A NaN distance orders last and fails the
191    /// distance check, so a NaN value is never chosen and a NaN `target` is left unsnapped.
192    fn find_closest(&self, values: &[f32], target: f32) -> Option<f32> {
193        values
194            .iter()
195            .min_by(|a, b| (target - **a).abs().total_cmp(&(target - **b).abs()))
196            .filter(|&&closest| (target - closest).abs() < self.options.snap_distance)
197            .copied()
198    }
199
200    /// Find closest point within magnet distance
201    fn find_closest_point<I>(&self, points: I, target: Pos2) -> Option<Pos2>
202    where
203        I: IntoIterator<Item = Pos2>,
204    {
205        let mut min_dist = self.options.magnet_distance;
206        let mut closest = None;
207
208        for p in points {
209            let dist = ((target.x - p.x).powi(2) + (target.y - p.y).powi(2)).sqrt();
210            if dist < min_dist {
211                min_dist = dist;
212                closest = Some(p);
213            }
214        }
215
216        closest
217    }
218
219    /// Check if a point would snap
220    ///
221    /// Returns true if the point would be modified by snapping.
222    pub fn would_snap(&self, point: Pos2, targets: &SnapTargets) -> bool {
223        let snapped = self.snap_point(point, targets);
224        (snapped.x - point.x).abs() > 0.001 || (snapped.y - point.y).abs() > 0.001
225    }
226
227    /// Get snap indicator position if snapping is active
228    ///
229    /// Returns the snapped position only if it differs from input,
230    /// useful for showing snap indicators in the UI.
231    pub fn get_snap_indicator(&self, point: Pos2, targets: &SnapTargets) -> Option<Pos2> {
232        let snapped = self.snap_point(point, targets);
233        if (snapped.x - point.x).abs() > 0.001 || (snapped.y - point.y).abs() > 0.001 {
234            Some(snapped)
235        } else {
236            None
237        }
238    }
239
240    /// Enable/disable snap to price
241    pub fn set_snap_to_price(&mut self, enabled: bool) {
242        self.options.snap_to_price = enabled;
243    }
244
245    /// Enable/disable snap to time
246    pub fn set_snap_to_time(&mut self, enabled: bool) {
247        self.options.snap_to_time = enabled;
248    }
249
250    /// Enable/disable magnet mode
251    pub fn set_magnet_mode(&mut self, enabled: bool) {
252        self.options.magnet_mode = enabled;
253    }
254
255    /// Set snap distance
256    pub fn set_snap_distance(&mut self, distance: f32) {
257        self.options.snap_distance = distance;
258    }
259
260    /// Set magnet distance
261    pub fn set_magnet_distance(&mut self, distance: f32) {
262        self.options.magnet_distance = distance;
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn snap_respects_targets_modes_and_distances() {
272        let defaults = SnapOptions::default();
273        let magnet = SnapOptions {
274            magnet_mode: true,
275            ..defaults.clone()
276        };
277        let disabled = SnapOptions {
278            snap_to_price: false,
279            snap_to_time: false,
280            ..defaults.clone()
281        };
282        // (options, targets, point, expected)
283        let cases = [
284            (
285                defaults.clone(),
286                SnapTargets::with_price_time(vec![100.0, 150.0, 200.0], vec![]),
287                Pos2::new(50.0, 152.0),
288                Pos2::new(50.0, 150.0),
289            ),
290            (
291                defaults.clone(),
292                SnapTargets::with_price_time(vec![], vec![100.0, 200.0, 300.0]),
293                Pos2::new(203.0, 50.0),
294                Pos2::new(200.0, 50.0),
295            ),
296            (
297                defaults.clone(),
298                SnapTargets::with_price_time(vec![100.0], vec![200.0]),
299                Pos2::new(250.0, 150.0),
300                Pos2::new(250.0, 150.0),
301            ),
302            (
303                magnet.clone(),
304                SnapTargets::new()
305                    .with_drawing_points(vec![Pos2::new(100.0, 100.0), Pos2::new(200.0, 200.0)]),
306                Pos2::new(105.0, 103.0),
307                Pos2::new(100.0, 100.0),
308            ),
309            (
310                SnapOptions {
311                    magnet_distance: 10.0,
312                    ..magnet
313                },
314                SnapTargets::new().with_drawing_points(vec![Pos2::new(100.0, 100.0)]),
315                Pos2::new(150.0, 150.0),
316                Pos2::new(150.0, 150.0),
317            ),
318            (
319                disabled,
320                SnapTargets::with_price_time(vec![100.0], vec![100.0]),
321                Pos2::new(101.0, 101.0),
322                Pos2::new(101.0, 101.0),
323            ),
324            (
325                defaults.clone(),
326                SnapTargets::with_price_time(vec![10.0, f32::NAN], vec![]),
327                Pos2::new(0.0, 10.5),
328                Pos2::new(0.0, 10.0),
329            ),
330            (
331                defaults.clone(),
332                SnapTargets::with_price_time(vec![f32::NAN, 10.0], vec![]),
333                Pos2::new(0.0, 10.5),
334                Pos2::new(0.0, 10.0),
335            ),
336            (
337                defaults.clone(),
338                SnapTargets::with_price_time(vec![10.0, 11.0], vec![]),
339                Pos2::new(0.0, f32::NAN),
340                Pos2::new(0.0, f32::NAN),
341            ),
342            (
343                defaults,
344                SnapTargets::with_price_time(vec![f32::INFINITY], vec![]),
345                Pos2::new(0.0, 10.5),
346                Pos2::new(0.0, 10.5),
347            ),
348        ];
349        for (i, (options, targets, point, expected)) in cases.into_iter().enumerate() {
350            let snapped = SnapService::with_options(options).snap_point(point, &targets);
351            for (got, want) in [(snapped.x, expected.x), (snapped.y, expected.y)] {
352                assert!(
353                    got == want || (got.is_nan() && want.is_nan()),
354                    "case {i}: {point:?}, {targets:?}: got {snapped:?}, want {expected:?}"
355                );
356            }
357        }
358    }
359
360    #[test]
361    fn test_would_snap() {
362        let service = SnapService::new();
363        let targets = SnapTargets::with_price_time(vec![100.0], vec![]);
364
365        assert!(service.would_snap(Pos2::new(50.0, 102.0), &targets));
366        assert!(!service.would_snap(Pos2::new(50.0, 150.0), &targets));
367    }
368
369    #[test]
370    fn test_snap_indicator() {
371        let service = SnapService::new();
372        let targets = SnapTargets::with_price_time(vec![100.0], vec![]);
373
374        // Should show indicator when snapping
375        let indicator = service.get_snap_indicator(Pos2::new(50.0, 102.0), &targets);
376        assert!(indicator.is_some());
377        assert_eq!(indicator.unwrap().y, 100.0);
378
379        // No indicator when not snapping
380        let indicator = service.get_snap_indicator(Pos2::new(50.0, 150.0), &targets);
381        assert!(indicator.is_none());
382    }
383}