Skip to main content

orbital_charts/engine/
curve.rs

1//! Line and area path generation with curve interpolation.
2
3use crate::CurveType;
4
5/// A point in plot space; `y` may be `None` for missing data.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct PlotPoint {
8    /// X coordinate in plot space.
9    pub x: f64,
10    /// Y coordinate; `None` represents a gap.
11    pub y: Option<f64>,
12}
13
14/// Generated SVG path and marker positions.
15#[derive(Clone, Debug, PartialEq)]
16pub struct LinePath {
17    /// SVG `d` attribute for the stroke/fill path.
18    pub d: String,
19    /// Marker positions for visible points.
20    pub markers: Vec<(f64, f64)>,
21}
22
23/// Build an SVG path through plot points.
24pub fn build_line_path(points: &[PlotPoint], curve: CurveType, connect_nulls: bool) -> LinePath {
25    let segments = split_segments(points, connect_nulls);
26    let mut d = String::new();
27    let mut markers = Vec::new();
28
29    for segment in segments {
30        if segment.is_empty() {
31            continue;
32        }
33        let coords: Vec<(f64, f64)> = segment
34            .iter()
35            .filter_map(|p| p.y.map(|y| (p.x, y)))
36            .collect();
37        if coords.is_empty() {
38            continue;
39        }
40        markers.extend(coords.iter().copied());
41        let segment_d = match curve {
42            CurveType::Linear => linear_path(&coords),
43            CurveType::Step => step_path(&coords),
44            CurveType::Monotone => monotone_path(&coords),
45            CurveType::Natural => natural_path(&coords),
46        };
47        if !d.is_empty() && !segment_d.is_empty() {
48            d.push(' ');
49        }
50        d.push_str(&segment_d);
51    }
52
53    LinePath { d, markers }
54}
55
56/// Build a closed area path from a line path down to a baseline y.
57pub fn build_area_path(line: &LinePath, baseline_y: f64) -> String {
58    if line.d.is_empty() || line.markers.is_empty() {
59        return String::new();
60    }
61    let first = line.markers[0];
62    let last = line.markers[line.markers.len() - 1];
63    format!(
64        "{line_d} L {last_x} {base} L {first_x} {base} Z",
65        line_d = line.d,
66        last_x = last.0,
67        base = baseline_y,
68        first_x = first.0,
69    )
70}
71
72fn split_segments(points: &[PlotPoint], connect_nulls: bool) -> Vec<Vec<PlotPoint>> {
73    if connect_nulls {
74        return vec![points.to_vec()];
75    }
76    let mut segments = Vec::new();
77    let mut current = Vec::new();
78    for p in points {
79        if p.y.is_none() {
80            if !current.is_empty() {
81                segments.push(current);
82                current = Vec::new();
83            }
84        } else {
85            current.push(*p);
86        }
87    }
88    if !current.is_empty() {
89        segments.push(current);
90    }
91    segments
92}
93
94fn linear_path(coords: &[(f64, f64)]) -> String {
95    let mut d = String::new();
96    for (i, (x, y)) in coords.iter().enumerate() {
97        if i == 0 {
98            d.push_str(&format!("M {x} {y}"));
99        } else {
100            d.push_str(&format!(" L {x} {y}"));
101        }
102    }
103    d
104}
105
106fn step_path(coords: &[(f64, f64)]) -> String {
107    let mut d = String::new();
108    for (i, (x, y)) in coords.iter().enumerate() {
109        if i == 0 {
110            d.push_str(&format!("M {x} {y}"));
111        } else {
112            d.push_str(&format!(" H {x} V {y}"));
113        }
114    }
115    d
116}
117
118fn monotone_path(coords: &[(f64, f64)]) -> String {
119    if coords.len() < 2 {
120        return linear_path(coords);
121    }
122    let mut d = format!("M {} {}", coords[0].0, coords[0].1);
123    for i in 0..coords.len() - 1 {
124        let (x0, y0) = coords[i];
125        let (x1, y1) = coords[i + 1];
126        let cx = (x0 + x1) / 2.0;
127        d.push_str(&format!(" C {cx} {y0}, {cx} {y1}, {x1} {y1}"));
128    }
129    d
130}
131
132fn natural_path(coords: &[(f64, f64)]) -> String {
133    monotone_path(coords)
134}
135
136/// Fingerprint for re-triggering path draw animation on data change.
137pub fn data_fingerprint(values: &[f64]) -> u64 {
138    use std::hash::{Hash, Hasher};
139    let mut hasher = std::collections::hash_map::DefaultHasher::new();
140    values.len().hash(&mut hasher);
141    for v in values {
142        v.to_bits().hash(&mut hasher);
143    }
144    hasher.finish()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn null_breaks_path_without_connect_nulls() {
153        let points = vec![
154            PlotPoint {
155                x: 0.0,
156                y: Some(1.0),
157            },
158            PlotPoint { x: 1.0, y: None },
159            PlotPoint {
160                x: 2.0,
161                y: Some(3.0),
162            },
163        ];
164        let path = build_line_path(&points, CurveType::Linear, false);
165        assert_eq!(path.markers.len(), 2);
166    }
167
168    #[test]
169    fn connect_nulls_keeps_single_segment() {
170        let points = vec![
171            PlotPoint {
172                x: 0.0,
173                y: Some(1.0),
174            },
175            PlotPoint { x: 1.0, y: None },
176            PlotPoint {
177                x: 2.0,
178                y: Some(3.0),
179            },
180        ];
181        let path = build_line_path(&points, CurveType::Linear, true);
182        assert!(path.d.starts_with('M'));
183    }
184
185    #[test]
186    fn area_path_closes_to_baseline() {
187        let line = build_line_path(
188            &[
189                PlotPoint {
190                    x: 0.0,
191                    y: Some(10.0),
192                },
193                PlotPoint {
194                    x: 50.0,
195                    y: Some(20.0),
196                },
197            ],
198            CurveType::Linear,
199            false,
200        );
201        let area = build_area_path(&line, 100.0);
202        assert!(area.ends_with('Z'));
203    }
204}